Skip to content

Commit 1d0a515

Browse files
Merge pull request #753 from gadget-inc/mill/ShadcnTableBulkActionAndSorting
Add column sort and bulk action system to Shadcn AutoTable
2 parents e3773e9 + 1637839 commit 1d0a515

20 files changed

+853
-513
lines changed

packages/react/cypress/component/auto/table/AutoTableBulkActions.cy.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
/* eslint-disable jest/valid-expect */
22
import React from "react";
3-
import { ActionErrorMessage, ActionSuccessMessage } from "../../../../src/auto/polaris/PolarisAutoBulkActionModal.js";
4-
import { PolarisAutoTable } from "../../../../src/auto/polaris/PolarisAutoTable.js";
3+
import { ActionErrorMessage, ActionSuccessMessage } from "../../../../src/auto/hooks/useTableBulkActions.js";
54
import { api } from "../../../support/api.js";
6-
import { PolarisWrapper } from "../../../support/auto.js";
5+
import { describeForEachAutoAdapter } from "../../../support/auto.js";
6+
import { SUITE_NAMES } from "../../../support/constants.js";
77
import { first50WidgetRecords, widgetModelMetadata } from "./metadata/widgetMetadata.js";
88

9-
describe("AutoTable - Bulk actions", () => {
9+
describeForEachAutoAdapter("AutoTable - Bulk actions", ({ name, adapter: { AutoTable }, wrapper }) => {
10+
const componentIdentifiers =
11+
name === SUITE_NAMES.POLARIS
12+
? {
13+
selectAllCheckbox: `input[id=":r3:"]`,
14+
singleRowCheckbox: (recordId: string | number) => `input[id="Select-${recordId}"]`,
15+
}
16+
: {
17+
// SHADCN
18+
selectAllCheckbox: `button[id="AutoTableSelectAllCheckbox"]`,
19+
singleRowCheckbox: (recordId: string | number) => `button[id="AutoTableSingleRowCheckbox-${recordId}"]`,
20+
};
21+
1022
const mockModelMetadata = () => {
1123
cy.intercept(
1224
{
@@ -50,13 +62,14 @@ describe("AutoTable - Bulk actions", () => {
5062
if (isPromoted) {
5163
return cy.contains(label).click({ force: true });
5264
}
53-
cy.get(`button[aria-label="More actions"]`).eq(1).click();
65+
66+
cy.get(`button[aria-label="More actions"]`).click({ multiple: true, force: true });
5467
return cy.contains(label).click({ force: true });
5568
};
5669

5770
const selectRecordIds = (ids: string[]) => {
5871
for (const id of ids) {
59-
cy.get(`input[id="Select-${id}"]`).eq(0).click();
72+
cy.get(componentIdentifiers.singleRowCheckbox(id)).eq(0).click();
6073
}
6174
};
6275

@@ -69,7 +82,7 @@ describe("AutoTable - Bulk actions", () => {
6982

7083
stubCallback = cy.stub();
7184
cy.mountWithWrapper(
72-
<PolarisAutoTable
85+
<AutoTable
7386
model={api.widget}
7487
actions={[
7588
"delete",
@@ -79,23 +92,23 @@ describe("AutoTable - Bulk actions", () => {
7992
{ promoted: true, label: "(Promoted)Relabeled model action", action: "delete" },
8093
]}
8194
/>,
82-
PolarisWrapper
95+
wrapper
8396
);
8497

8598
cy.wait("@getModelMetadata");
8699
cy.wait("@getWidgets").its("request.body.variables").should("deep.equal", { first: 50 }); // No search value
87100
});
88101

89102
it("can select and deselect all records on the current page", () => {
90-
cy.get(`input[id=":r3:"]`).eq(0).click();
103+
cy.get(componentIdentifiers.selectAllCheckbox).eq(0).click();
91104
cy.contains("50 selected").should("exist");
92105

93106
openBulkAction("Delete");
94107

95108
cy.contains("Are you sure you want to run this action on 50 records?").should("exist");
96-
cy.get("button").contains("Close").click();
109+
cy.get("button").contains("Close").click({ force: true });
97110

98-
cy.get(`input[id=":r3:"]`).eq(0).click();
111+
cy.get(componentIdentifiers.selectAllCheckbox).eq(0).click();
99112
cy.get(`button[aria-label="Actions"]`).should("not.exist");
100113
});
101114

@@ -115,7 +128,7 @@ describe("AutoTable - Bulk actions", () => {
115128
cy.wait("@getWidgets").its("request.body.variables").should("deep.equal", { first: 50 }); // No search value
116129

117130
cy.contains(ActionSuccessMessage);
118-
cy.get("button").contains("Close").click();
131+
cy.get("button").contains("Close").click({ force: true });
119132

120133
// Now ensure that error response appears in the modal
121134
selectRecordIds(["20", "21", "22"]);
@@ -153,7 +166,7 @@ describe("AutoTable - Bulk actions", () => {
153166
cy.wait("@getWidgets").its("request.body.variables").should("deep.equal", { first: 50 }); // No search value
154167

155168
cy.contains(ActionSuccessMessage);
156-
cy.get("button").contains("Close").click();
169+
cy.get("button").contains("Close").click({ force: true });
157170
});
158171
});
159172
});

packages/react/cypress/support/auto.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const ONLY_RUN_SUITES = {
3838
"AutoRoleInput",
3939
"AutoEnumInput",
4040
"AutoBelongsToInput",
41+
// Table
42+
"AutoTable - Bulk actions",
4143
],
4244
};
4345

packages/react/package.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,14 @@
7474
"@pollyjs/adapter-xhr": "^6.0.6",
7575
"@pollyjs/core": "^6.0.6",
7676
"@pollyjs/persister-fs": "^6.0.6",
77-
"@radix-ui/react-toast": "^1.2.4",
78-
"@radix-ui/react-label": "^2.1.1",
77+
"@radix-ui/react-avatar": "^1.1.2",
7978
"@radix-ui/react-checkbox": "^1.1.3",
79+
"@radix-ui/react-dialog": "^1.1.5",
80+
"@radix-ui/react-label": "^2.1.1",
8081
"@radix-ui/react-popover": "^1.1.5",
81-
"react-day-picker": "^9.5.1",
8282
"@radix-ui/react-scroll-area": "^1.2.2",
83-
"cmdk": "^1.0.4",
83+
"@radix-ui/react-toast": "^1.2.4",
84+
"@radix-ui/react-tooltip": "^1.1.7",
8485
"@shopify/polaris": "^12.0.0",
8586
"@shopify/polaris-icons": "^8.1.0",
8687
"@storybook/addon-essentials": "^8.1.6",
@@ -91,6 +92,7 @@
9192
"@storybook/react": "^8.1.6",
9293
"@storybook/react-vite": "^8.1.6",
9394
"@storybook/test": "^8.1.6",
95+
"@tanstack/react-table": "^8.20.6",
9496
"@testing-library/jest-dom": "^5.17.0",
9597
"@testing-library/react": "^13.4.0",
9698
"@testing-library/user-event": "^14.5.2",
@@ -106,6 +108,7 @@
106108
"autoprefixer": "^10.4.20",
107109
"class-variance-authority": "^0.7.1",
108110
"clsx": "^2.1.1",
111+
"cmdk": "^1.0.4",
109112
"conditional-type-checks": "^1.0.6",
110113
"copyfiles": "^2.4.1",
111114
"cypress": "^13.13.0",
@@ -120,10 +123,8 @@
120123
"lucide-react": "^0.471.0",
121124
"postcss": "^8.4.49",
122125
"react": "^18.2.0",
126+
"react-day-picker": "^9.5.1",
123127
"react-dom": "^18.2.0",
124-
"@radix-ui/react-avatar": "^1.1.2",
125-
"@radix-ui/react-tooltip": "^1.1.7",
126-
"@tanstack/react-table": "^8.20.6",
127128
"setup-polly-jest": "^0.11.0",
128129
"storybook": "^8.1.6",
129130
"tailwind-merge": "^2.6.0",

packages/react/spec/auto/shadcn-defaults/components/Checkbox.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
2-
import { Check } from "lucide-react";
2+
import { Check, Minus } from "lucide-react";
33
import * as React from "react";
44

55
import { cn } from "../utils.js";
@@ -17,7 +17,7 @@ const Checkbox = React.forwardRef<
1717
{...props}
1818
>
1919
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
20-
<Check className="h-4 w-4" />
20+
{props.checked === "indeterminate" ? <Minus className="h-4 w-4" /> : <Check className="h-4 w-4" />}
2121
</CheckboxPrimitive.Indicator>
2222
</CheckboxPrimitive.Root>
2323
));
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* From https://ui.shadcn.com/docs/components/dialog
3+
*/
4+
5+
"use client";
6+
7+
import * as DialogPrimitive from "@radix-ui/react-dialog";
8+
import { X } from "lucide-react";
9+
import * as React from "react";
10+
11+
import { cn } from "../utils.js";
12+
13+
const Dialog = DialogPrimitive.Root;
14+
15+
const DialogTrigger = DialogPrimitive.Trigger;
16+
17+
const DialogPortal = DialogPrimitive.Portal;
18+
19+
const DialogClose = DialogPrimitive.Close;
20+
21+
const DialogOverlay = React.forwardRef<
22+
React.ElementRef<typeof DialogPrimitive.Overlay>,
23+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
24+
>(({ className, ...props }, ref) => (
25+
<DialogPrimitive.Overlay
26+
ref={ref}
27+
className={cn(
28+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
29+
className
30+
)}
31+
{...props}
32+
/>
33+
));
34+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
35+
36+
const DialogContent = React.forwardRef<
37+
React.ElementRef<typeof DialogPrimitive.Content>,
38+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
39+
>(({ className, children, ...props }, ref) => (
40+
<DialogPortal>
41+
<DialogOverlay />
42+
<DialogPrimitive.Content
43+
ref={ref}
44+
className={cn(
45+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg ",
46+
className
47+
)}
48+
{...props}
49+
>
50+
{children}
51+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
52+
<X className="h-4 w-4" />
53+
<span className="sr-only">Close</span>
54+
</DialogPrimitive.Close>
55+
</DialogPrimitive.Content>
56+
</DialogPortal>
57+
));
58+
DialogContent.displayName = DialogPrimitive.Content.displayName;
59+
60+
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
61+
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
62+
);
63+
DialogHeader.displayName = "DialogHeader";
64+
65+
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
66+
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
67+
);
68+
DialogFooter.displayName = "DialogFooter";
69+
70+
const DialogTitle = React.forwardRef<
71+
React.ElementRef<typeof DialogPrimitive.Title>,
72+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
73+
>(({ className, ...props }, ref) => (
74+
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
75+
));
76+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
77+
78+
const DialogDescription = React.forwardRef<
79+
React.ElementRef<typeof DialogPrimitive.Description>,
80+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
81+
>(({ className, ...props }, ref) => (
82+
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
83+
));
84+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
85+
86+
export {
87+
Dialog,
88+
DialogClose,
89+
DialogContent,
90+
DialogDescription,
91+
DialogFooter,
92+
DialogHeader,
93+
DialogOverlay,
94+
DialogPortal,
95+
DialogTitle,
96+
DialogTrigger,
97+
};

packages/react/spec/auto/shadcn-defaults/index.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ import {
1616
CommandLoading,
1717
CommandSeparator,
1818
} from "./components/Command.js";
19+
import {
20+
Dialog,
21+
DialogClose,
22+
DialogContent,
23+
DialogDescription,
24+
DialogFooter,
25+
DialogHeader,
26+
DialogOverlay,
27+
DialogPortal,
28+
DialogTitle,
29+
DialogTrigger,
30+
} from "./components/Dialog.js";
1931
import { Form } from "./components/Form.js";
2032
import { Input } from "./components/Input.js";
2133
import { Label } from "./components/Label.js";
@@ -91,6 +103,17 @@ export const elements: ShadcnElements = {
91103
AvatarImage,
92104
AvatarFallback,
93105

106+
Dialog,
107+
DialogContent,
108+
DialogDescription,
109+
DialogHeader,
110+
DialogTitle,
111+
DialogTrigger,
112+
DialogClose,
113+
DialogFooter,
114+
DialogPortal,
115+
DialogOverlay,
116+
94117
toast,
95118
cn,
96119
};

packages/react/spec/auto/storybook/table/AutoTable.stories.jsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ export default {
3636
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
3737
layout: "padded",
3838
},
39-
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
40-
tags: ["autodocs"],
41-
// More on argTypes: https://storybook.js.org/docs/api/argtypes
39+
40+
tags: [], // More on argTypes: https://storybook.js.org/docs/api/argtypes
4241
};
4342

4443
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type React from "react";
2+
import { useState } from "react";
3+
4+
/** Used for tracking hover state on an element */
5+
export function useHover<T extends HTMLElement = any>(): [
6+
boolean,
7+
{ onMouseEnter: React.MouseEventHandler<T>; onMouseLeave: React.MouseEventHandler<T> }
8+
] {
9+
const [value, setValue] = useState(false);
10+
11+
const onMouseEnter = () => setValue(true);
12+
const onMouseLeave = () => setValue(false);
13+
14+
return [value, { onMouseEnter, onMouseLeave }];
15+
}

0 commit comments

Comments
 (0)