@handled-ai/design-system 0.16.1 → 0.17.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/dist/components/contextual-quick-action-launcher.d.ts +32 -0
- package/dist/components/contextual-quick-action-launcher.js +202 -0
- package/dist/components/contextual-quick-action-launcher.js.map +1 -0
- package/dist/components/data-table-condition-filter.js +26 -9
- package/dist/components/data-table-condition-filter.js.map +1 -1
- package/dist/components/data-table-filter.js +3 -14
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/score-why-chips.d.ts +46 -0
- package/dist/components/score-why-chips.js +281 -0
- package/dist/components/score-why-chips.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/prototype/index.d.ts +1 -1
- package/dist/prototype/prototype-config.d.ts +37 -1
- package/dist/prototype/prototype-inbox-view.d.ts +9 -3
- package/dist/prototype/prototype-inbox-view.js +28 -96
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +193 -0
- package/src/components/__tests__/data-table-condition-filter.test.tsx +26 -0
- package/src/components/__tests__/data-table-filter.test.tsx +21 -0
- package/src/components/contextual-quick-action-launcher.tsx +231 -0
- package/src/components/data-table-condition-filter.tsx +39 -11
- package/src/components/data-table-filter.tsx +3 -19
- package/src/components/score-why-chips.tsx +358 -0
- package/src/index.ts +2 -0
- package/src/prototype/__tests__/detail-view-score-why.test.tsx +326 -0
- package/src/prototype/prototype-config.ts +35 -0
- package/src/prototype/prototype-inbox-view.tsx +31 -104
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ContextualQuickActionLauncher,
|
|
7
|
+
type ContextualQuickActionItem,
|
|
8
|
+
} from "../contextual-quick-action-launcher";
|
|
9
|
+
|
|
10
|
+
vi.mock("../dropdown-menu", async () => {
|
|
11
|
+
const ReactMod = await import("react");
|
|
12
|
+
|
|
13
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
14
|
+
const DropdownMenu = ({ children, open, defaultOpen, onOpenChange }: any) => {
|
|
15
|
+
const [internalOpen, setInternalOpen] = ReactMod.useState(defaultOpen ?? false);
|
|
16
|
+
const isControlled = open !== undefined;
|
|
17
|
+
const currentOpen = isControlled ? open : internalOpen;
|
|
18
|
+
const setOpen = (nextOpen: boolean) => {
|
|
19
|
+
if (!isControlled) setInternalOpen(nextOpen);
|
|
20
|
+
onOpenChange?.(nextOpen);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return ReactMod.createElement(
|
|
24
|
+
"div",
|
|
25
|
+
{
|
|
26
|
+
"data-slot": "dropdown-menu",
|
|
27
|
+
"data-open": currentOpen ? "true" : "false",
|
|
28
|
+
},
|
|
29
|
+
ReactMod.Children.map(children, (child: any) => {
|
|
30
|
+
if (!ReactMod.isValidElement(child)) return child;
|
|
31
|
+
return ReactMod.cloneElement(child, { __open: currentOpen, __setOpen: setOpen } as any);
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const DropdownMenuTrigger = ({ children, asChild, __open, __setOpen, ...props }: any) => {
|
|
37
|
+
const childProps = {
|
|
38
|
+
"data-slot": "dropdown-menu-trigger",
|
|
39
|
+
"aria-expanded": __open ? "true" : "false",
|
|
40
|
+
onClick: () => __setOpen?.(!__open),
|
|
41
|
+
...props,
|
|
42
|
+
};
|
|
43
|
+
if (asChild && ReactMod.isValidElement(children)) {
|
|
44
|
+
return ReactMod.cloneElement(children, childProps);
|
|
45
|
+
}
|
|
46
|
+
return ReactMod.createElement("button", childProps, children);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const DropdownMenuContent = ({
|
|
50
|
+
children,
|
|
51
|
+
__open,
|
|
52
|
+
__setOpen: _setOpen,
|
|
53
|
+
className,
|
|
54
|
+
align: _align,
|
|
55
|
+
side: _side,
|
|
56
|
+
sideOffset: _sideOffset,
|
|
57
|
+
...props
|
|
58
|
+
}: any) => {
|
|
59
|
+
if (!__open) return null;
|
|
60
|
+
return ReactMod.createElement(
|
|
61
|
+
"div",
|
|
62
|
+
{ "data-slot": "dropdown-menu-content", role: "menu", className, ...props },
|
|
63
|
+
children
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const DropdownMenuItem = ({ children, onSelect, disabled, className, ...props }: any) =>
|
|
68
|
+
ReactMod.createElement(
|
|
69
|
+
"button",
|
|
70
|
+
{
|
|
71
|
+
"data-slot": "dropdown-menu-item",
|
|
72
|
+
role: "menuitem",
|
|
73
|
+
type: "button",
|
|
74
|
+
disabled,
|
|
75
|
+
className,
|
|
76
|
+
onClick: (event: any) => onSelect?.(event),
|
|
77
|
+
...props,
|
|
78
|
+
},
|
|
79
|
+
children
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const DropdownMenuSeparator = ({ className, ...props }: any) =>
|
|
83
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu-separator", className, ...props });
|
|
84
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
DropdownMenu,
|
|
88
|
+
DropdownMenuTrigger,
|
|
89
|
+
DropdownMenuContent,
|
|
90
|
+
DropdownMenuItem,
|
|
91
|
+
DropdownMenuSeparator,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const items: ContextualQuickActionItem[] = [
|
|
96
|
+
{
|
|
97
|
+
id: "create-opportunity",
|
|
98
|
+
label: "Create an opportunity",
|
|
99
|
+
description: "New sales or expansion deal",
|
|
100
|
+
icon: <span data-testid="salesforce-icon">SF</span>,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "update-opportunity",
|
|
104
|
+
label: "Update an opportunity",
|
|
105
|
+
description: "Edit stage, close date, or details",
|
|
106
|
+
disabled: true,
|
|
107
|
+
disabledReason: "No opportunity",
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
function renderLauncher(
|
|
112
|
+
props: Partial<React.ComponentProps<typeof ContextualQuickActionLauncher>> = {}
|
|
113
|
+
) {
|
|
114
|
+
return render(
|
|
115
|
+
<ContextualQuickActionLauncher
|
|
116
|
+
contextLabel="Supabase"
|
|
117
|
+
contextSecondary="Account"
|
|
118
|
+
items={items}
|
|
119
|
+
onSelect={() => {}}
|
|
120
|
+
{...props}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("ContextualQuickActionLauncher", () => {
|
|
126
|
+
it("renders the accessible trigger name", () => {
|
|
127
|
+
renderLauncher();
|
|
128
|
+
|
|
129
|
+
expect(screen.getByRole("button", { name: /quick action/i })).not.toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("opens and closes in uncontrolled mode", () => {
|
|
133
|
+
renderLauncher();
|
|
134
|
+
|
|
135
|
+
expect(screen.queryByText("Acting on")).toBeNull();
|
|
136
|
+
|
|
137
|
+
fireEvent.click(screen.getByRole("button", { name: /quick action/i }));
|
|
138
|
+
expect(screen.getByText("Acting on")).not.toBeNull();
|
|
139
|
+
expect(screen.getByText("Supabase")).not.toBeNull();
|
|
140
|
+
|
|
141
|
+
fireEvent.click(screen.getByRole("button", { name: /quick action/i }));
|
|
142
|
+
expect(screen.queryByText("Acting on")).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("selects enabled items and closes the menu", () => {
|
|
146
|
+
const onSelect = vi.fn();
|
|
147
|
+
renderLauncher({ onSelect });
|
|
148
|
+
|
|
149
|
+
fireEvent.click(screen.getByRole("button", { name: /quick action/i }));
|
|
150
|
+
fireEvent.click(screen.getByRole("menuitem", { name: /create an opportunity/i }));
|
|
151
|
+
|
|
152
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
153
|
+
expect(onSelect).toHaveBeenCalledWith(items[0]);
|
|
154
|
+
expect(screen.queryByText("Acting on")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("does not select disabled items", () => {
|
|
158
|
+
const onSelect = vi.fn();
|
|
159
|
+
renderLauncher({ onSelect });
|
|
160
|
+
|
|
161
|
+
fireEvent.click(screen.getByRole("button", { name: /quick action/i }));
|
|
162
|
+
fireEvent.click(screen.getByRole("menuitem", { name: /update an opportunity/i }));
|
|
163
|
+
|
|
164
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
165
|
+
expect(screen.getByText("No opportunity")).not.toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("calls browse all and closes the menu", () => {
|
|
169
|
+
const onBrowseAll = vi.fn();
|
|
170
|
+
renderLauncher({ onBrowseAll });
|
|
171
|
+
|
|
172
|
+
fireEvent.click(screen.getByRole("button", { name: /quick action/i }));
|
|
173
|
+
fireEvent.click(screen.getByRole("button", { name: /browse all actions/i }));
|
|
174
|
+
|
|
175
|
+
expect(onBrowseAll).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(screen.queryByText("Acting on")).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("renders optional hint text", () => {
|
|
180
|
+
renderLauncher({ showHint: true });
|
|
181
|
+
|
|
182
|
+
expect(screen.getByText(/or press/i)).not.toBeNull();
|
|
183
|
+
expect(screen.getByText("⌘K")).not.toBeNull();
|
|
184
|
+
expect(screen.getByText(/for all actions/i)).not.toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("uses pointer-events-auto on portalled content", () => {
|
|
188
|
+
renderLauncher({ defaultOpen: true });
|
|
189
|
+
|
|
190
|
+
const content = document.querySelector('[data-slot="dropdown-menu-content"]');
|
|
191
|
+
expect(content?.className).toContain("pointer-events-auto");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -151,6 +151,32 @@ describe("DataTableConditionFilter", () => {
|
|
|
151
151
|
expect(onConditionsChange).toHaveBeenCalledWith([]);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
|
|
155
|
+
it("preserves an in-progress draft when fields are recreated with the same definition", () => {
|
|
156
|
+
const { rerender } = render(
|
|
157
|
+
<DataTableConditionFilter
|
|
158
|
+
fields={[...allFields]}
|
|
159
|
+
conditions={[]}
|
|
160
|
+
onConditionsChange={onConditionsChange}
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
fireEvent.click(screen.getByText("Add filter"));
|
|
165
|
+
fireEvent.change(screen.getByPlaceholderText("Enter value..."), {
|
|
166
|
+
target: { value: "Acme" },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
rerender(
|
|
170
|
+
<DataTableConditionFilter
|
|
171
|
+
fields={[...allFields]}
|
|
172
|
+
conditions={[]}
|
|
173
|
+
onConditionsChange={onConditionsChange}
|
|
174
|
+
/>,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect((screen.getByPlaceholderText("Enter value...") as HTMLInputElement).value).toBe("Acme");
|
|
178
|
+
});
|
|
179
|
+
|
|
154
180
|
it("commits a text draft with Apply after a value is entered", () => {
|
|
155
181
|
render(
|
|
156
182
|
<DataTableConditionFilter
|
|
@@ -294,6 +294,27 @@ describe("DataTableFilter", () => {
|
|
|
294
294
|
expect(screen.getByText("Filter builder")).toBeDefined();
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
+
|
|
298
|
+
it("opens the condition builder without closing the menu selection", () => {
|
|
299
|
+
render(
|
|
300
|
+
<DataTableFilter
|
|
301
|
+
{...defaultProps}
|
|
302
|
+
conditionFields={[{ id: "name", label: "Name", type: "text" }]}
|
|
303
|
+
/>
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const builderEntry = document.querySelector('[data-slot="popover-trigger"]');
|
|
307
|
+
expect(builderEntry).not.toBeNull();
|
|
308
|
+
|
|
309
|
+
const preventDefaultSpy = vi.fn();
|
|
310
|
+
const mockEvent = new MouseEvent("click", { bubbles: true });
|
|
311
|
+
Object.defineProperty(mockEvent, "preventDefault", { value: preventDefaultSpy });
|
|
312
|
+
builderEntry!.dispatchEvent(mockEvent);
|
|
313
|
+
|
|
314
|
+
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
315
|
+
expect(screen.getByText("Filter builder")).toBeDefined();
|
|
316
|
+
});
|
|
317
|
+
|
|
297
318
|
it("uses the custom condition builder label when provided", () => {
|
|
298
319
|
render(
|
|
299
320
|
<DataTableFilter
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ArrowRight, ChevronDown, Zap } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from "./dropdown-menu"
|
|
14
|
+
|
|
15
|
+
export interface ContextualQuickActionItem {
|
|
16
|
+
id: string
|
|
17
|
+
label: string
|
|
18
|
+
description?: string
|
|
19
|
+
icon?: React.ReactNode
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
disabledReason?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ContextualQuickActionContextLabelProps {
|
|
25
|
+
contextLabel: string
|
|
26
|
+
contextSecondary?: string
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ContextualQuickActionLauncherProps {
|
|
31
|
+
contextLabel: string
|
|
32
|
+
contextSecondary?: string
|
|
33
|
+
items: ContextualQuickActionItem[]
|
|
34
|
+
onSelect: (item: ContextualQuickActionItem) => void
|
|
35
|
+
onBrowseAll?: () => void
|
|
36
|
+
showHint?: boolean
|
|
37
|
+
align?: "start" | "end"
|
|
38
|
+
className?: string
|
|
39
|
+
open?: boolean
|
|
40
|
+
defaultOpen?: boolean
|
|
41
|
+
onOpenChange?: (open: boolean) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ContextualQuickActionContextLabel({
|
|
45
|
+
contextLabel,
|
|
46
|
+
contextSecondary,
|
|
47
|
+
className,
|
|
48
|
+
}: ContextualQuickActionContextLabelProps) {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
data-slot="contextual-quick-action-context-label"
|
|
52
|
+
className={cn(
|
|
53
|
+
"-mx-1 -mt-1 mb-1 flex items-center gap-1.5 border-b px-3 py-2 text-[11px] text-muted-foreground",
|
|
54
|
+
className
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<Zap className="h-3 w-3 shrink-0" strokeWidth={2.25} aria-hidden="true" />
|
|
58
|
+
<span>Acting on</span>
|
|
59
|
+
<strong className="max-w-[180px] truncate font-semibold text-foreground">
|
|
60
|
+
{contextLabel}
|
|
61
|
+
</strong>
|
|
62
|
+
{contextSecondary ? (
|
|
63
|
+
<span className="min-w-0 truncate text-muted-foreground">
|
|
64
|
+
· {contextSecondary}
|
|
65
|
+
</span>
|
|
66
|
+
) : null}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function DefaultActionIcon() {
|
|
72
|
+
return <Zap className="h-3.5 w-3.5" aria-hidden="true" />
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ContextualQuickActionLauncher({
|
|
76
|
+
contextLabel,
|
|
77
|
+
contextSecondary,
|
|
78
|
+
items,
|
|
79
|
+
onSelect,
|
|
80
|
+
onBrowseAll,
|
|
81
|
+
showHint = false,
|
|
82
|
+
align = "start",
|
|
83
|
+
className,
|
|
84
|
+
open,
|
|
85
|
+
defaultOpen,
|
|
86
|
+
onOpenChange,
|
|
87
|
+
}: ContextualQuickActionLauncherProps) {
|
|
88
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false)
|
|
89
|
+
const isControlled = open !== undefined
|
|
90
|
+
const isOpen = isControlled ? open : uncontrolledOpen
|
|
91
|
+
|
|
92
|
+
const handleOpenChange = React.useCallback(
|
|
93
|
+
(nextOpen: boolean) => {
|
|
94
|
+
if (!isControlled) {
|
|
95
|
+
setUncontrolledOpen(nextOpen)
|
|
96
|
+
}
|
|
97
|
+
onOpenChange?.(nextOpen)
|
|
98
|
+
},
|
|
99
|
+
[isControlled, onOpenChange]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const closeMenu = React.useCallback(() => {
|
|
103
|
+
handleOpenChange(false)
|
|
104
|
+
}, [handleOpenChange])
|
|
105
|
+
|
|
106
|
+
const handleSelect = React.useCallback(
|
|
107
|
+
(item: ContextualQuickActionItem, event?: Event) => {
|
|
108
|
+
if (item.disabled) {
|
|
109
|
+
event?.preventDefault()
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
onSelect(item)
|
|
114
|
+
closeMenu()
|
|
115
|
+
},
|
|
116
|
+
[closeMenu, onSelect]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const handleBrowseAll = React.useCallback(() => {
|
|
120
|
+
onBrowseAll?.()
|
|
121
|
+
closeMenu()
|
|
122
|
+
}, [closeMenu, onBrowseAll])
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
data-slot="contextual-quick-action-launcher"
|
|
127
|
+
className={cn("inline-flex items-center gap-2", className)}
|
|
128
|
+
>
|
|
129
|
+
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
|
130
|
+
<DropdownMenuTrigger asChild>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
data-slot="contextual-quick-action-trigger"
|
|
134
|
+
data-state={isOpen ? "open" : "closed"}
|
|
135
|
+
className={cn(
|
|
136
|
+
"inline-flex h-8 items-center gap-2 rounded-lg border border-border bg-background py-1.5 pr-2.5 pl-2 text-xs font-medium text-foreground shadow-sm transition-colors hover:border-muted-foreground/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
137
|
+
"data-[state=open]:border-foreground data-[state=open]:bg-foreground data-[state=open]:text-background data-[state=open]:hover:bg-foreground"
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
<span
|
|
141
|
+
className={cn(
|
|
142
|
+
"inline-flex h-[18px] w-[18px] items-center justify-center rounded bg-foreground/[0.04]",
|
|
143
|
+
isOpen && "bg-background/15"
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<Zap className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
|
|
147
|
+
</span>
|
|
148
|
+
<span className="tracking-[-0.005em]">Quick action</span>
|
|
149
|
+
<ChevronDown className="h-3 w-3 opacity-60" strokeWidth={2} aria-hidden="true" />
|
|
150
|
+
</button>
|
|
151
|
+
</DropdownMenuTrigger>
|
|
152
|
+
|
|
153
|
+
<DropdownMenuContent
|
|
154
|
+
align={align}
|
|
155
|
+
side="bottom"
|
|
156
|
+
sideOffset={6}
|
|
157
|
+
className="pointer-events-auto w-[308px] rounded-[10px] p-1.5 text-[12.5px] shadow-[0_12px_28px_rgba(0,0,0,0.12),0_2px_6px_rgba(0,0,0,0.04)]"
|
|
158
|
+
>
|
|
159
|
+
<ContextualQuickActionContextLabel
|
|
160
|
+
contextLabel={contextLabel}
|
|
161
|
+
contextSecondary={contextSecondary}
|
|
162
|
+
/>
|
|
163
|
+
|
|
164
|
+
{items.map((item) => (
|
|
165
|
+
<DropdownMenuItem
|
|
166
|
+
key={item.id}
|
|
167
|
+
disabled={item.disabled}
|
|
168
|
+
onSelect={(event) => handleSelect(item, event)}
|
|
169
|
+
className={cn(
|
|
170
|
+
"flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left outline-none focus:bg-accent data-[disabled]:cursor-not-allowed data-[disabled]:opacity-100",
|
|
171
|
+
item.disabled && "text-muted-foreground"
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
<span
|
|
175
|
+
data-slot="contextual-quick-action-item-icon"
|
|
176
|
+
className={cn(
|
|
177
|
+
"flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-md bg-secondary text-foreground",
|
|
178
|
+
item.disabled && "opacity-60"
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
{item.icon ?? <DefaultActionIcon />}
|
|
182
|
+
</span>
|
|
183
|
+
<span className="min-w-0 flex-1">
|
|
184
|
+
<span className="block truncate text-[12.5px] font-medium leading-tight text-current">
|
|
185
|
+
{item.label}
|
|
186
|
+
</span>
|
|
187
|
+
{item.description ? (
|
|
188
|
+
<span className="mt-0.5 block truncate text-[11px] leading-tight text-muted-foreground">
|
|
189
|
+
{item.description}
|
|
190
|
+
</span>
|
|
191
|
+
) : null}
|
|
192
|
+
</span>
|
|
193
|
+
{item.disabled && item.disabledReason ? (
|
|
194
|
+
<span className="ml-auto shrink-0 text-[10.5px] italic text-muted-foreground/80">
|
|
195
|
+
{item.disabledReason}
|
|
196
|
+
</span>
|
|
197
|
+
) : null}
|
|
198
|
+
</DropdownMenuItem>
|
|
199
|
+
))}
|
|
200
|
+
|
|
201
|
+
<DropdownMenuSeparator className="-mx-1.5 my-1" />
|
|
202
|
+
<div className="flex items-center justify-between px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
className="inline-flex items-center gap-1 font-medium text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
206
|
+
onClick={handleBrowseAll}
|
|
207
|
+
>
|
|
208
|
+
Browse all actions
|
|
209
|
+
<ArrowRight className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
|
|
210
|
+
</button>
|
|
211
|
+
<span className="inline-flex items-center rounded border border-border px-1 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
|
|
212
|
+
⌘K
|
|
213
|
+
</span>
|
|
214
|
+
</div>
|
|
215
|
+
</DropdownMenuContent>
|
|
216
|
+
</DropdownMenu>
|
|
217
|
+
|
|
218
|
+
{showHint ? (
|
|
219
|
+
<span className="text-[11px] text-muted-foreground">
|
|
220
|
+
Or press{" "}
|
|
221
|
+
<span className="inline-flex items-center rounded border border-border bg-muted/40 px-1 py-0.5 text-[10px] font-medium leading-none">
|
|
222
|
+
⌘K
|
|
223
|
+
</span>{" "}
|
|
224
|
+
for all actions
|
|
225
|
+
</span>
|
|
226
|
+
) : null}
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export { ContextualQuickActionContextLabel, ContextualQuickActionLauncher }
|
|
@@ -171,6 +171,27 @@ function isCompleteCondition(
|
|
|
171
171
|
return condition.value !== null && condition.value !== ""
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
function getConditionsSignature(conditions: ConditionFilterValue[]): string {
|
|
175
|
+
return conditions
|
|
176
|
+
.map((condition) => `${condition.id}:${condition.field}:${condition.operator}:${String(condition.value)}`)
|
|
177
|
+
.join(";")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getFieldsSignature(fields: ConditionFieldDef[]): string {
|
|
181
|
+
return fields
|
|
182
|
+
.map((field) => `${field.id}:${field.type}:${field.label}:${(field.operators ?? []).join("|")}`)
|
|
183
|
+
.join(";")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getCommittedConditions(
|
|
187
|
+
drafts: ConditionFilterValue[],
|
|
188
|
+
fields: ConditionFieldDef[],
|
|
189
|
+
): ConditionFilterValue[] {
|
|
190
|
+
return drafts
|
|
191
|
+
.map((condition) => normalizeCondition(condition, fields))
|
|
192
|
+
.filter((condition) => isCompleteCondition(condition, fields))
|
|
193
|
+
}
|
|
194
|
+
|
|
174
195
|
function getFieldIcon(type: ConditionFieldDef["type"]) {
|
|
175
196
|
if (type === "currency") return DollarSign
|
|
176
197
|
if (type === "number") return Hash
|
|
@@ -363,17 +384,26 @@ function DataTableConditionFilter({
|
|
|
363
384
|
conditions.map((condition) => normalizeCondition(condition, fields)),
|
|
364
385
|
)
|
|
365
386
|
|
|
387
|
+
const fieldsSignature = React.useMemo(() => getFieldsSignature(fields), [fields])
|
|
388
|
+
const conditionsSignature = React.useMemo(() => getConditionsSignature(conditions), [conditions])
|
|
389
|
+
const fieldsRef = React.useRef(fields)
|
|
390
|
+
|
|
391
|
+
React.useEffect(() => {
|
|
392
|
+
setDrafts(conditions.map((condition) => normalizeCondition(condition, fieldsRef.current)))
|
|
393
|
+
}, [conditionsSignature])
|
|
394
|
+
|
|
366
395
|
React.useEffect(() => {
|
|
367
|
-
|
|
368
|
-
|
|
396
|
+
if (fieldsRef.current !== fields) {
|
|
397
|
+
fieldsRef.current = fields
|
|
398
|
+
setDrafts((current) => current.map((condition) => normalizeCondition(condition, fields)))
|
|
399
|
+
}
|
|
400
|
+
// Depend on a structural signature so inline-but-equivalent field arrays do
|
|
401
|
+
// not wipe in-progress drafts before Apply.
|
|
402
|
+
}, [fieldsSignature, fields])
|
|
369
403
|
|
|
370
404
|
const commitDrafts = React.useCallback(
|
|
371
405
|
(nextDrafts: ConditionFilterValue[] = drafts) => {
|
|
372
|
-
onConditionsChange(
|
|
373
|
-
nextDrafts
|
|
374
|
-
.map((condition) => normalizeCondition(condition, fields))
|
|
375
|
-
.filter((condition) => isCompleteCondition(condition, fields)),
|
|
376
|
-
)
|
|
406
|
+
onConditionsChange(getCommittedConditions(nextDrafts, fields))
|
|
377
407
|
},
|
|
378
408
|
[drafts, fields, onConditionsChange],
|
|
379
409
|
)
|
|
@@ -381,12 +411,10 @@ function DataTableConditionFilter({
|
|
|
381
411
|
const handleAdd = () => {
|
|
382
412
|
const firstField = fields[0]
|
|
383
413
|
if (!firstField) return
|
|
384
|
-
const committedDrafts = drafts
|
|
385
|
-
isCompleteCondition(condition, fields),
|
|
386
|
-
)
|
|
414
|
+
const committedDrafts = getCommittedConditions(drafts, fields)
|
|
387
415
|
const nextDrafts = [...committedDrafts, createDraftCondition(firstField)]
|
|
388
416
|
setDrafts(nextDrafts)
|
|
389
|
-
|
|
417
|
+
onConditionsChange(committedDrafts)
|
|
390
418
|
}
|
|
391
419
|
|
|
392
420
|
const handleUpdate = (index: number, updated: ConditionFilterValue) => {
|
|
@@ -111,29 +111,13 @@ export function DataTableFilter({
|
|
|
111
111
|
)
|
|
112
112
|
|
|
113
113
|
const activeCount = React.useMemo(() => {
|
|
114
|
-
// Count user-selected filters
|
|
115
114
|
const userCount = Object.values(selectedFilters).reduce(
|
|
116
115
|
(count, selected) => count + selected.length,
|
|
117
116
|
0
|
|
118
117
|
)
|
|
119
118
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (presetFilters) {
|
|
123
|
-
for (const [categoryId, presetValues] of Object.entries(presetFilters)) {
|
|
124
|
-
for (const value of presetValues) {
|
|
125
|
-
// Only count if the preset is active (in selectedFilters) but NOT already counted as a user filter
|
|
126
|
-
if (selectedFilters[categoryId]?.includes(value)) {
|
|
127
|
-
// Already counted in userCount, skip
|
|
128
|
-
} else {
|
|
129
|
-
// Not in selectedFilters — it's an inactive preset, don't count
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return userCount + presetCount + conditionFilters.length
|
|
136
|
-
}, [selectedFilters, presetFilters, conditionFilters.length])
|
|
119
|
+
return userCount + conditionFilters.length
|
|
120
|
+
}, [selectedFilters, conditionFilters.length])
|
|
137
121
|
|
|
138
122
|
/** Collect all preset chips to render */
|
|
139
123
|
const presetChips = React.useMemo(() => {
|
|
@@ -345,7 +329,7 @@ export function DataTableFilter({
|
|
|
345
329
|
align="start"
|
|
346
330
|
side="right"
|
|
347
331
|
sideOffset={8}
|
|
348
|
-
className="z-
|
|
332
|
+
className="z-50 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
|
349
333
|
onEscapeKeyDown={() => setConditionBuilderOpen(false)}
|
|
350
334
|
onInteractOutside={(event) => {
|
|
351
335
|
const target = event.target as HTMLElement | null
|