mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-09 03:21:23 +00:00
beta key list page (#565)
* init beta key list * show key only if user is creator or referrer * fix console errors with keys * partially working filters * sort header fixes * remove unused stuff * updates
This commit is contained in:
1 parent
9592e84a11
commit
8b06cbefa8
11 files changed
+460
-25
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import MakeRequest from "../MakeRequest";
|
||||
import { Pagination } from "../_common/SharedFetchTypes";
|
||||
|
||||
export interface BetaKey {
|
||||
created_at: Date;
|
||||
creator: {
|
||||
default_avatar: {
|
||||
color_index: number;
|
||||
image_index: number;
|
||||
};
|
||||
display_name: string;
|
||||
gravatar_hash: string;
|
||||
user_token: string;
|
||||
username: string;
|
||||
};
|
||||
key_value: string;
|
||||
maybe_redeemed_at?: Date;
|
||||
maybe_redeemer?: {
|
||||
default_avatar: {
|
||||
color_index: number;
|
||||
image_index: number;
|
||||
};
|
||||
display_name: string;
|
||||
gravatar_hash: string;
|
||||
user_token: string;
|
||||
username: string;
|
||||
} | null;
|
||||
maybe_referrer?: {
|
||||
default_avatar: {
|
||||
color_index: number;
|
||||
image_index: number;
|
||||
};
|
||||
display_name: string;
|
||||
gravatar_hash: string;
|
||||
user_token: string;
|
||||
username: string;
|
||||
} | null;
|
||||
product: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ListBetaKeysRequest {}
|
||||
|
||||
export interface ListBetaKeysResponse {
|
||||
beta_keys: BetaKey[];
|
||||
pagination: Pagination;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export const ListBetaKeys = MakeRequest<
|
||||
string,
|
||||
ListBetaKeysRequest,
|
||||
ListBetaKeysResponse,
|
||||
{}
|
||||
>({
|
||||
method: "GET",
|
||||
routingFunction: () => "/v1/beta_keys/list",
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import MakeRequest from "../MakeRequest";
|
||||
import { MediaFile } from "./GetMedia";
|
||||
import { LazyPagination } from "../_common/SharedFetchTypes";
|
||||
import { Pagination } from "../_common/SharedFetchTypes";
|
||||
|
||||
export interface ListMediaFilesRequest {}
|
||||
|
||||
export interface ListMediaFilesResponse {
|
||||
pagination: LazyPagination;
|
||||
pagination: Pagination;
|
||||
success: boolean;
|
||||
results: MediaFile[];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@react-spring/types": "^9.7.3",
|
||||
"@react-spring/web": "^9.7.3",
|
||||
"@storyteller/components": "0.1.0",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.5.0",
|
||||
"@testing-library/user-event": "^7.2.1",
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface SelectProps extends ReactSelectProps {
|
||||
options: Option[];
|
||||
onChange?: (value: any) => void;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
@@ -40,13 +41,13 @@ export default function Select({
|
||||
const isMulti = Array.isArray(value);
|
||||
const findVal = (opts: Option[], nest = 0): Option | undefined => {
|
||||
let val: Option | undefined;
|
||||
opts.forEach((option) => {
|
||||
opts.forEach(option => {
|
||||
if (!val) {
|
||||
if (option.options) {
|
||||
val = findVal(option.options,++nest);
|
||||
val = findVal(option.options, ++nest);
|
||||
} else if (option.value === value) {
|
||||
val = option
|
||||
}
|
||||
val = option;
|
||||
}
|
||||
}
|
||||
});
|
||||
return val;
|
||||
@@ -55,11 +56,17 @@ export default function Select({
|
||||
const valueLabel = findVal(options)?.label || "";
|
||||
const onChange = (option: any, x: any) => {
|
||||
if (Array.isArray(option)) {
|
||||
inChange({ target: {value: option.map(({ value = "" }) => value), name, type: "select" } });
|
||||
inChange({
|
||||
target: {
|
||||
value: option.map(({ value = "" }) => value),
|
||||
name,
|
||||
type: "select",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
inChange({ target: {value: option.value, name, type: "select" } });
|
||||
inChange({ target: { value: option.value, name, type: "select" } });
|
||||
}
|
||||
}
|
||||
};
|
||||
const className = `${icon ? " input-icon" : ""}${
|
||||
small ? " select-small" : ""
|
||||
}`;
|
||||
@@ -97,9 +104,14 @@ export default function Select({
|
||||
name,
|
||||
onChange,
|
||||
options,
|
||||
...value && (isMulti ? {
|
||||
value: Array.isArray(value) ? value.map((val: any) => ({ label: val, value: val })) : []
|
||||
} : { value: { label: valueLabel, value } }),
|
||||
...(value &&
|
||||
(isMulti
|
||||
? {
|
||||
value: Array.isArray(value)
|
||||
? value.map((val: any) => ({ label: val, value: val }))
|
||||
: [],
|
||||
}
|
||||
: { value: { label: valueLabel, value } })),
|
||||
...rest,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,8 @@ export enum ModalView { // ignore this modal stuff for now -V
|
||||
Login,
|
||||
}
|
||||
|
||||
interface ModalProps { // this too
|
||||
interface ModalProps {
|
||||
// this too
|
||||
close: () => void;
|
||||
open: () => void;
|
||||
view: ModalView;
|
||||
@@ -32,7 +33,7 @@ interface SessionContextType {
|
||||
querySubscriptions?: any;
|
||||
sessionFetched: boolean;
|
||||
sessionSubscriptions?: any;
|
||||
studioAccessCheck: (x:any) => any,
|
||||
studioAccessCheck: (x: any) => any;
|
||||
user?: any;
|
||||
userTokenMatch: (token: string) => boolean;
|
||||
}
|
||||
@@ -75,12 +76,18 @@ export default function SessionProvider({
|
||||
sessionSubscriptions,
|
||||
sessionWrapper,
|
||||
}: Props) {
|
||||
const sessionResponse = sessionWrapper?.sessionStateResponse || { logged_in: false, user: null };
|
||||
const sessionResponse = sessionWrapper?.sessionStateResponse || {
|
||||
logged_in: false,
|
||||
user: null,
|
||||
};
|
||||
const { logged_in: loggedIn, user } = sessionResponse;
|
||||
const [view, viewSet] = useState(ModalView.Closed);
|
||||
const open = () => viewSet(ModalView.Signup);
|
||||
const close = () => { viewSet(ModalView.Closed); };
|
||||
const viewSwitch = () => viewSet(view === ModalView.Signup ? ModalView.Login : ModalView.Signup);
|
||||
const close = () => {
|
||||
viewSet(ModalView.Closed);
|
||||
};
|
||||
const viewSwitch = () =>
|
||||
viewSet(view === ModalView.Signup ? ModalView.Login : ModalView.Signup);
|
||||
const check = () => {
|
||||
if (user) {
|
||||
return true;
|
||||
@@ -90,7 +97,9 @@ export default function SessionProvider({
|
||||
}
|
||||
};
|
||||
const userTokenMatch = (otherUserToken: string) =>
|
||||
!otherUserToken || !user?.user_token ? false : user.user_token === otherUserToken;
|
||||
!otherUserToken || !user?.user_token
|
||||
? false
|
||||
: user.user_token === otherUserToken;
|
||||
const canEditTtsModel = (userToken: string) =>
|
||||
user?.can_delete_other_users_tts_models || userTokenMatch(userToken);
|
||||
const canEditMediaFile = (userToken: string) =>
|
||||
@@ -98,11 +107,14 @@ export default function SessionProvider({
|
||||
const canBanUsers = () => user?.can_ban_users || false;
|
||||
const canAccessStudio = () => {
|
||||
const hostnameAllowed = StudioRolloutHostnameAllowed();
|
||||
const userAllowed = !!user?.can_access_studio || !!user?.maybe_feature_flags.includes("studio");
|
||||
const userAllowed =
|
||||
!!user?.can_access_studio ||
|
||||
!!user?.maybe_feature_flags.includes("studio");
|
||||
return hostnameAllowed && userAllowed;
|
||||
}
|
||||
};
|
||||
|
||||
const studioAccessCheck = (content: React.ElementType) => canAccessStudio() ? content : <StudioNotAvailable />;
|
||||
const studioAccessCheck = (content: React.ElementType) =>
|
||||
canAccessStudio() ? content : <StudioNotAvailable />;
|
||||
|
||||
const modal = { close, open, view };
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ import StyleVideo from "./pages/style-video";
|
||||
import CreateBetaKeyPage from "./pages/beta_key/CreateBetaKeyPage";
|
||||
import RedeemBetaKeyPage from "./pages/beta_key/RedeemBetaKeyPage";
|
||||
import RedeemSuccessPage from "./pages/beta_key/RedeemSuccessPage";
|
||||
import BetaKeysListPage from "./pages/beta_key/BetaKeysListPage";
|
||||
|
||||
interface Props {
|
||||
sessionWrapper: SessionWrapper;
|
||||
@@ -1139,6 +1140,10 @@ class PageContainer extends React.Component<
|
||||
<RedeemBetaKeyPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/beta-key/list">
|
||||
<BetaKeysListPage />
|
||||
</Route>
|
||||
|
||||
{/*
|
||||
// <Route path="/engine-compositor">
|
||||
// <EngineCompositor
|
||||
|
||||
@@ -26,3 +26,11 @@
|
||||
border: 4px solid lighten($primary, 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
width: 80px;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faCheck,
|
||||
faTimes,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
Column,
|
||||
} from "@tanstack/react-table";
|
||||
import { Container, Panel, TempInput } from "components/common";
|
||||
import PageHeader from "components/layout/PageHeader";
|
||||
import {
|
||||
BetaKey,
|
||||
ListBetaKeys,
|
||||
ListBetaKeysResponse,
|
||||
} from "@storyteller/components/src/api/beta_key/ListBetaKeys";
|
||||
import "./BetaKey.scss";
|
||||
import { Gravatar } from "@storyteller/components/src/elements/Gravatar";
|
||||
|
||||
interface CustomColumnMeta {
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
canFilter?: boolean;
|
||||
}
|
||||
|
||||
const Filter = ({ column }: { column: Column<BetaKey, unknown> }) => {
|
||||
const columnFilterValue = column.getFilterValue();
|
||||
|
||||
if (column.id === "maybe_redeemed_at") {
|
||||
return (
|
||||
// <TempSelect
|
||||
// value={(columnFilterValue ?? "") as string}
|
||||
// onChange={e => column.setFilterValue(e.target.value)}
|
||||
// className="my-2 py-1"
|
||||
// options={[
|
||||
// { value: "", label: "All" },
|
||||
// { value: "redeemed", label: "Redeemed" },
|
||||
// { value: "not_redeemed", label: "Not Redeemed" },
|
||||
// ]}
|
||||
// />\
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TempInput
|
||||
type="text"
|
||||
value={(columnFilterValue ?? "") as string}
|
||||
onChange={e => column.setFilterValue(e.target.value)}
|
||||
placeholder={`Search...`}
|
||||
className="my-2 py-1"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default function BetaKeysListPage() {
|
||||
const [data, setData] = useState<BetaKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response: ListBetaKeysResponse = await ListBetaKeys("", {});
|
||||
if (response.success) {
|
||||
setData(response.beta_keys);
|
||||
} else {
|
||||
console.error("Error fetching data: API call unsuccessful");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const columnHelper = createColumnHelper<BetaKey>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor("maybe_redeemed_at", {
|
||||
header: "Redeemed?",
|
||||
cell: info => {
|
||||
const value = info.getValue();
|
||||
return value ? (
|
||||
<FontAwesomeIcon icon={faCheck} className="fs-5 text-success" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faTimes} className="fs-5 text-danger" />
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: "status-cell",
|
||||
headerClassName: "status-header",
|
||||
canFilter: true,
|
||||
} as CustomColumnMeta,
|
||||
filterFn: (row, columnId, filterValue) => {
|
||||
const redeemed = row.getValue(columnId);
|
||||
if (filterValue === "redeemed") {
|
||||
return !!redeemed;
|
||||
}
|
||||
if (filterValue === "not_redeemed") {
|
||||
return !redeemed;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("key_value", {
|
||||
header: "Key",
|
||||
cell: info => {
|
||||
const key = info.getValue();
|
||||
|
||||
if (key) {
|
||||
return key;
|
||||
} else {
|
||||
return "********";
|
||||
}
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("maybe_referrer.username", {
|
||||
header: "Referrer",
|
||||
cell: info => {
|
||||
const username = info.getValue();
|
||||
const userData = info.row.original.maybe_referrer;
|
||||
const userEmailHash = userData?.gravatar_hash || "";
|
||||
|
||||
return username ? (
|
||||
<div className="d-flex gap-1 align-items-center">
|
||||
<Gravatar
|
||||
size={18}
|
||||
username={username}
|
||||
email_hash={userEmailHash}
|
||||
avatarIndex={userData?.default_avatar.image_index}
|
||||
backgroundIndex={userData?.default_avatar.color_index}
|
||||
/>
|
||||
<Link to={`/profile/${username}`}>{username}</Link>
|
||||
</div>
|
||||
) : (
|
||||
"None"
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("maybe_redeemer.username", {
|
||||
header: "Redeemed by",
|
||||
cell: info => {
|
||||
const username = info.getValue();
|
||||
const userData = info.row.original.maybe_redeemer;
|
||||
const userEmailHash = userData?.gravatar_hash || "";
|
||||
return username ? (
|
||||
<div className="d-flex gap-1 align-items-center">
|
||||
<Gravatar
|
||||
size={18}
|
||||
username={username}
|
||||
email_hash={userEmailHash}
|
||||
avatarIndex={userData?.default_avatar.image_index}
|
||||
backgroundIndex={userData?.default_avatar.color_index}
|
||||
/>
|
||||
<Link to={`/profile/${username}`}>{username}</Link>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("created_at", {
|
||||
header: "Created Date",
|
||||
cell: info => new Date(info.getValue()).toLocaleDateString(),
|
||||
}),
|
||||
columnHelper.accessor("maybe_redeemed_at", {
|
||||
header: "Redemption Date",
|
||||
cell: info => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value).toLocaleDateString() : "-";
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("creator.username", {
|
||||
header: "Key Creator",
|
||||
cell: info => {
|
||||
const username = info.getValue();
|
||||
const userData = info.row.original.creator;
|
||||
const userEmailHash = userData?.gravatar_hash || "";
|
||||
return (
|
||||
<div className="d-flex gap-1 align-items-center">
|
||||
<Gravatar
|
||||
size={18}
|
||||
username={username}
|
||||
email_hash={userEmailHash}
|
||||
avatarIndex={userData?.default_avatar.image_index}
|
||||
backgroundIndex={userData?.default_avatar.color_index}
|
||||
/>
|
||||
<Link to={`/profile/${username}`}>{username}</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
columnFilters,
|
||||
sorting,
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Container type="panel-full">
|
||||
<PageHeader title="Beta Keys List" subText="List of beta keys created" />
|
||||
<Panel padding={true}>
|
||||
<div>
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-responsive">
|
||||
<table className="table w-100">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map(header => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<>
|
||||
<div
|
||||
{...{
|
||||
className: header.column.getCanSort()
|
||||
? "cursor-pointer select-none"
|
||||
: "",
|
||||
onClick:
|
||||
header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getIsSorted() ? (
|
||||
header.column.getIsSorted() === "asc" ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCaretUp}
|
||||
className="ms-2 opacity-75"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faCaretDown}
|
||||
className="ms-2 opacity-75"
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
{header.column.getCanFilter() ? (
|
||||
<div>
|
||||
<Filter column={header.column} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map(row => (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={
|
||||
(cell.column.columnDef.meta as CustomColumnMeta)
|
||||
?.className
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.length === 0 && (
|
||||
<div className="mt-5">No data available</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -101,11 +101,10 @@ export default function CreateBetaKeyPage() {
|
||||
<Panel padding={true}>
|
||||
<div className="d-flex flex-column gap-3">
|
||||
<TempInput
|
||||
label="Username"
|
||||
placeholder="Username of key owner"
|
||||
label="Username of Referrer"
|
||||
placeholder="Username of referrer (optional)"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required={true}
|
||||
/>
|
||||
<TempInput
|
||||
label="Number of Keys"
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function RedeemBetaKeyPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
const history = useHistory();
|
||||
const { loggedIn } = useSession();
|
||||
const { user, loggedIn } = useSession();
|
||||
|
||||
const handleGoToSuccess = () => {
|
||||
history.push("/beta-key/redeem/success");
|
||||
@@ -78,6 +78,8 @@ export default function RedeemBetaKeyPage() {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container type="panel" className="redeem-container">
|
||||
|
||||
@@ -4472,6 +4472,7 @@ __metadata:
|
||||
resolution: "@storyteller/root@workspace:."
|
||||
dependencies:
|
||||
"@react-spring/types": ^9.7.3
|
||||
"@tanstack/react-table": ^8.17.3
|
||||
"@typeform/embed": ^4.8.1
|
||||
"@typeform/embed-react": ^3.15.0
|
||||
craco: ^0.0.3
|
||||
@@ -4757,6 +4758,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/react-table@npm:^8.17.3":
|
||||
version: 8.17.3
|
||||
resolution: "@tanstack/react-table@npm:8.17.3"
|
||||
dependencies:
|
||||
"@tanstack/table-core": 8.17.3
|
||||
peerDependencies:
|
||||
react: ">=16.8"
|
||||
react-dom: ">=16.8"
|
||||
checksum: ec635f9c6e66bc5655e71ef5bb5087c78fca79e619d8cf80ba395152d88e72ef78e87cbb3e4b9e0b4d86504068c8bf5ba320309529ef9d3dd1ee4630bf75067a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/table-core@npm:8.17.3":
|
||||
version: 8.17.3
|
||||
resolution: "@tanstack/table-core@npm:8.17.3"
|
||||
checksum: 4f7115d6198f63bea6a00a1c8f33288453463afbede716365b99b3ffa565c46861eb2903c26d3d1de35211ad85fa13bc1e0f35b3546b8f7fc46218441cf06700
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@testing-library/dom@npm:*":
|
||||
version: 8.11.3
|
||||
resolution: "@testing-library/dom@npm:8.11.3"
|
||||
|
||||
Reference in new issue
Block a user