diff --git a/.changeset/gentle-pots-enjoy.md b/.changeset/gentle-pots-enjoy.md new file mode 100644 index 0000000000..c6e75da20a --- /dev/null +++ b/.changeset/gentle-pots-enjoy.md @@ -0,0 +1,5 @@ +--- +"@medusajs/ui": patch +--- + +feature(ui): Adds a `size` variant to `` component, and prevent clicks from propigating to parent elements". Also adds additional sizes to the `` component. diff --git a/packages/admin-next/dashboard/public/locales/en/translation.json b/packages/admin-next/dashboard/public/locales/en/translation.json index 57dfc15faf..8d0c34af31 100644 --- a/packages/admin-next/dashboard/public/locales/en/translation.json +++ b/packages/admin-next/dashboard/public/locales/en/translation.json @@ -27,6 +27,8 @@ "details": "Details", "enabled": "Enabled", "disabled": "Disabled", + "active": "Active", + "revoked": "Revoked", "remove": "Remove", "admin": "Admin", "store": "Store", @@ -183,10 +185,16 @@ "domain": "API Key Management", "createKey": "Create key", "createPublishableApiKey": "Create Publishable API Key", + "editKey": "Edit key", "revoke": "Revoke", "publishableApiKeyHint": "Publishable API keys are used to limit the scope of requests to specific sales channels.", "deleteKeyWarning": "You are about to delete the API key {{title}}. This action cannot be undone.", - "revokeKeyWarning": "You are about to revoke the API key {{title}}." + "revokeKeyWarning": "You are about to revoke the API key {{title}}. This action cannot be undone, and the key cannot be used in future requests.", + "removeSalesChannelWarning": "You are about to remove the sales channel {{name}} from the API key. This action cannot be undone.", + "removeSalesChannelsWarning_one": "You are about to remove {{count}} sales channel from the API key. This action cannot be undone.", + "removeSalesChannelsWarning_other": "You are about to remove {{count}} sales channels from the API key. This action cannot be undone.", + "createdBy": "Created by", + "revokedBy": "Revoked by" }, "fields": { "name": "Name", @@ -234,6 +242,8 @@ "variants": "Variants", "orders": "Orders", "account": "Account", - "total": "Total" + "total": "Total", + "created": "Created", + "key": "Key" } } diff --git a/packages/admin-next/dashboard/src/components/common/user-link/index.ts b/packages/admin-next/dashboard/src/components/common/user-link/index.ts new file mode 100644 index 0000000000..951235f824 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/user-link/index.ts @@ -0,0 +1 @@ +export * from "./user-link" diff --git a/packages/admin-next/dashboard/src/components/common/user-link/user-link.tsx b/packages/admin-next/dashboard/src/components/common/user-link/user-link.tsx new file mode 100644 index 0000000000..1a23adcf61 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/user-link/user-link.tsx @@ -0,0 +1,34 @@ +import { Avatar, Text } from "@medusajs/ui" +import { Link } from "react-router-dom" + +type UserLinkProps = { + id: string + first_name?: string | null + last_name?: string | null + email: string + type?: "customer" | "user" +} + +export const UserLink = ({ + id, + first_name, + last_name, + email, + type = "user", +}: UserLinkProps) => { + const name = [first_name, last_name].filter(Boolean).join(" ") + const fallback = name ? name.slice(0, 1) : email.slice(0, 1) + const link = type === "user" ? `/settings/users/${id}` : `/customers/${id}` + + return ( + + + + {name || email} + + + ) +} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx index 6ccd1178e7..c1ccf72385 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx @@ -3,7 +3,9 @@ import type { AdminCustomerGroupsRes, AdminCustomersRes, AdminProductsRes, + AdminPublishableApiKeysRes, AdminRegionsRes, + AdminSalesChannelsRes, } from "@medusajs/medusa" import { Outlet, @@ -487,6 +489,10 @@ const router = createBrowserRouter([ path: ":id", lazy: () => import("../../routes/sales-channels/sales-channel-detail"), + handle: { + crumb: (data: AdminSalesChannelsRes) => + data.sales_channel.name, + }, children: [ { path: "edit", @@ -533,6 +539,10 @@ const router = createBrowserRouter([ import( "../../routes/api-key-management/api-key-management-detail" ), + handle: { + crumb: (data: AdminPublishableApiKeysRes) => + data.publishable_api_key.title, + }, children: [ { path: "edit", @@ -541,6 +551,13 @@ const router = createBrowserRouter([ "../../routes/api-key-management/api-key-management-edit" ), }, + { + path: "add-sales-channels", + lazy: () => + import( + "../../routes/api-key-management/api-key-management-add-sales-channels" + ), + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/api-key-management-add-sales-channels.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/api-key-management-add-sales-channels.tsx new file mode 100644 index 0000000000..9e8a62caf7 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/api-key-management-add-sales-channels.tsx @@ -0,0 +1,36 @@ +import { FocusModal } from "@medusajs/ui" +import { useAdminPublishableApiKeySalesChannels } from "medusa-react" +import { useParams } from "react-router-dom" +import { useRouteModalState } from "../../../hooks/use-route-modal-state" +import { AddSalesChannelsToApiKeyForm } from "./components" + +export const ApiKeyManagementAddSalesChannels = () => { + const { id } = useParams() + const [open, onOpenChange, subscribe] = useRouteModalState() + + const { sales_channels, isLoading, isError, error } = + useAdminPublishableApiKeySalesChannels(id!) + + const handleSuccessfulSubmit = () => { + onOpenChange(false, true) + } + + if (isError) { + throw error + } + + return ( + + + {!isLoading && sales_channels && ( + sc.id)} + onSuccessfulSubmit={handleSuccessfulSubmit} + subscribe={subscribe} + /> + )} + + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/components/add-sales-channels-to-api-key-form.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/components/add-sales-channels-to-api-key-form.tsx new file mode 100644 index 0000000000..902dee4fc9 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/components/add-sales-channels-to-api-key-form.tsx @@ -0,0 +1,326 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { SalesChannel } from "@medusajs/medusa" +import { + Button, + Checkbox, + FocusModal, + Hint, + StatusBadge, + Table, + Tooltip, + clx, +} from "@medusajs/ui" +import { + PaginationState, + RowSelectionState, + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { + useAdminAddPublishableKeySalesChannelsBatch, + useAdminSalesChannels, +} from "medusa-react" +import { useEffect, useMemo, useState } from "react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" +import { Form } from "../../../../components/common/form" +import { OrderBy } from "../../../../components/filtering/order-by" +import { Query } from "../../../../components/filtering/query" +import { LocalizedTablePagination } from "../../../../components/localization/localized-table-pagination" +import { useQueryParams } from "../../../../hooks/use-query-params" + +type AddSalesChannelsToApiKeyFormProps = { + apiKey: string + preSelected: string[] + subscribe: (state: boolean) => void + onSuccessfulSubmit: () => void +} + +const AddSalesChannelsToApiKeySchema = zod.object({ + sales_channel_ids: zod.array(zod.string()).min(1), +}) + +const PAGE_SIZE = 50 + +export const AddSalesChannelsToApiKeyForm = ({ + apiKey, + preSelected, + subscribe, + onSuccessfulSubmit, +}: AddSalesChannelsToApiKeyFormProps) => { + const { t } = useTranslation() + + const form = useForm>({ + defaultValues: { + sales_channel_ids: [], + }, + resolver: zodResolver(AddSalesChannelsToApiKeySchema), + }) + + const { + formState: { isDirty }, + } = form + + useEffect(() => { + subscribe(isDirty) + }, [isDirty]) + + const { mutateAsync, isLoading: isMutating } = + useAdminAddPublishableKeySalesChannelsBatch(apiKey) + + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ) + + const [rowSelection, setRowSelection] = useState({}) + + useEffect(() => { + form.setValue( + "sales_channel_ids", + Object.keys(rowSelection).filter((k) => rowSelection[k]) + ) + }, [rowSelection]) + + const params = useQueryParams(["q", "order"]) + const { sales_channels, count } = useAdminSalesChannels( + { + limit: PAGE_SIZE, + offset: PAGE_SIZE * pageIndex, + ...params, + }, + { + keepPreviousData: true, + } + ) + + const columns = useColumns() + + const table = useReactTable({ + data: sales_channels ?? [], + columns, + pageCount: Math.ceil((count ?? 0) / PAGE_SIZE), + state: { + pagination, + rowSelection, + }, + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + getRowId: (row) => row.id, + enableRowSelection: (row) => { + return !preSelected.includes(row.id) + }, + meta: { + preSelected, + }, + }) + + const handleSubmit = form.handleSubmit(async (values) => { + await mutateAsync( + { + sales_channel_ids: values.sales_channel_ids.map((p) => ({ id: p })), + }, + { + onSuccess: () => { + onSuccessfulSubmit() + }, + } + ) + }) + + return ( +
+ + +
+ {form.formState.errors.sales_channel_ids && ( + + {form.formState.errors.sales_channel_ids.message} + + )} + + + + +
+
+ +
+
+
+ + +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+
+
+ +
+
+
+ + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row, table }) => { + const { preSelected } = table.options.meta as { + preSelected: string[] + } + + const isAdded = preSelected.includes(row.original.id) + const isSelected = row.getIsSelected() || isAdded + + const Component = ( + row.toggleSelected(!!value)} + /> + ) + + if (isAdded) { + return ( + + {Component} + + ) + } + + return Component + }, + }), + columnHelper.accessor("name", { + header: t("fields.name"), + cell: ({ getValue }) => getValue(), + }), + columnHelper.accessor("description", { + header: t("fields.description"), + cell: ({ getValue }) => ( +
+ {getValue()} +
+ ), + }), + columnHelper.accessor("is_disabled", { + header: t("fields.status"), + cell: ({ getValue }) => { + const value = getValue() + return ( +
+ + {value ? t("general.disabled") : t("general.enabled")} + +
+ ) + }, + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/components/index.ts b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/components/index.ts new file mode 100644 index 0000000000..4e4781c229 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/components/index.ts @@ -0,0 +1 @@ +export * from "./add-sales-channels-to-api-key-form" diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/index.ts b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/index.ts new file mode 100644 index 0000000000..8c278126e7 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-add-sales-channels/index.ts @@ -0,0 +1 @@ +export { ApiKeyManagementAddSalesChannels as Component } from "./api-key-management-add-sales-channels" diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-create/api-key-management-create.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-create/api-key-management-create.tsx index ef7c98898e..cfc8522b0c 100644 --- a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-create/api-key-management-create.tsx +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-create/api-key-management-create.tsx @@ -8,7 +8,7 @@ export const ApiKeyManagementCreate = () => { return ( - + ) diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-create/components/create-publishable-api-key-form/create-publishable-api-key-form.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-create/components/create-publishable-api-key-form/create-publishable-api-key-form.tsx index dd5c196b83..d7766fed21 100644 --- a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-create/components/create-publishable-api-key-form/create-publishable-api-key-form.tsx +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-create/components/create-publishable-api-key-form/create-publishable-api-key-form.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next" import * as zod from "zod" import { useEffect } from "react" +import { useNavigate } from "react-router-dom" import { Form } from "../../../../../components/common/form" type CreatePublishableApiKeyFormProps = { @@ -37,9 +38,16 @@ export const CreatePublishableApiKeyForm = ({ }, [isDirty]) const { t } = useTranslation() + const navigate = useNavigate() const handleSubmit = form.handleSubmit(async (values) => { - await mutateAsync(values) + await mutateAsync(values, { + onSuccess: ({ publishable_api_key }) => { + navigate(`/settings/api-key-management/${publishable_api_key.id}`, { + replace: true, + }) + }, + }) }) return ( diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/api-key-management-detail.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/api-key-management-detail.tsx index 169888b96e..358886d640 100644 --- a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/api-key-management-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/api-key-management-detail.tsx @@ -1,3 +1,39 @@ +import { useAdminPublishableApiKey } from "medusa-react" +import { Outlet, json, useLoaderData, useParams } from "react-router-dom" +import { JsonViewSection } from "../../../components/common/json-view-section" +import { ApiKeyGeneralSection } from "./components/api-key-general-section" +import { ApiKeySalesChannelSection } from "./components/api-key-sales-channel-section" +import { apiKeyLoader } from "./loader" + export const ApiKeyManagementDetail = () => { - return
+ const initialData = useLoaderData() as Awaited< + ReturnType + > + + const { id } = useParams() + const { publishable_api_key, isLoading, isError, error } = + useAdminPublishableApiKey(id!, { + initialData, + }) + + if (isLoading) { + return
Loading...
+ } + + if (isError || !publishable_api_key) { + if (error) { + throw error + } + + throw json("An unknown error occurred", 500) + } + + return ( +
+ + + + +
+ ) } diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-general-section/api-key-general-section.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-general-section/api-key-general-section.tsx new file mode 100644 index 0000000000..89ebed001d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-general-section/api-key-general-section.tsx @@ -0,0 +1,180 @@ +import { PencilSquare, Trash, XCircle } from "@medusajs/icons" +import { PublishableApiKey } from "@medusajs/medusa" +import { + Container, + Copy, + Heading, + StatusBadge, + Text, + usePrompt, +} from "@medusajs/ui" +import { + useAdminDeletePublishableApiKey, + useAdminRevokePublishableApiKey, + useAdminUser, +} from "medusa-react" +import { useTranslation } from "react-i18next" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { Skeleton } from "../../../../../components/common/skeleton" +import { UserLink } from "../../../../../components/common/user-link" + +type ApiKeyGeneralSectionProps = { + apiKey: PublishableApiKey +} + +export const ApiKeyGeneralSection = ({ apiKey }: ApiKeyGeneralSectionProps) => { + const { t } = useTranslation() + const prompt = usePrompt() + + const { mutateAsync: revokeAsync } = useAdminRevokePublishableApiKey( + apiKey.id + ) + const { mutateAsync: deleteAsync } = useAdminDeletePublishableApiKey( + apiKey.id + ) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("apiKeyManagement.deleteKeyWarning", { + title: apiKey.title, + }), + confirmText: t("general.delete"), + cancelText: t("general.cancel"), + }) + + if (!res) { + return + } + + await deleteAsync() + } + + const handleRevoke = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("apiKeyManagement.revokeKeyWarning", { + title: apiKey.title, + }), + confirmText: t("apiKeyManagement.revoke"), + cancelText: t("general.cancel"), + }) + + if (!res) { + return + } + + await revokeAsync() + } + + const dangerousActions = [ + { + icon: , + label: t("general.delete"), + onClick: handleDelete, + }, + ] + + if (!apiKey.revoked_at) { + dangerousActions.unshift({ + icon: , + label: t("apiKeyManagement.revoke"), + onClick: handleRevoke, + }) + } + + return ( + +
+ {apiKey.title} +
+ + {apiKey.revoked_at ? t("general.revoked") : t("general.active")} + + , + to: `/settings/api-key-management/${apiKey.id}/edit`, + }, + ], + }, + { + actions: dangerousActions, + }, + ]} + /> +
+
+
+ + {t("fields.key")} + +
+ + {apiKey.id} + + +
+
+
+ + {t("apiKeyManagement.createdBy")} + + +
+ {apiKey.revoked_at && ( +
+ + {t("apiKeyManagement.revokedBy")} + + +
+ )} +
+ ) +} + +const ActionBy = ({ userId }: { userId: string | null }) => { + const { user, isLoading, isError, error } = useAdminUser(userId!, { + enabled: !!userId, + }) + + if (!userId) { + return ( + + - + + ) + } + + if (isError) { + throw error + } + + if (isLoading) { + return ( +
+ + +
+ ) + } + + if (!user) { + return ( + + - + + ) + } + + return +} diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-general-section/index.ts b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-general-section/index.ts new file mode 100644 index 0000000000..eb2a2a50ed --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-general-section/index.ts @@ -0,0 +1 @@ +export * from "./api-key-general-section" diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/api-key-sales-channel-section.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/api-key-sales-channel-section.tsx new file mode 100644 index 0000000000..3071e1e3f5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/api-key-sales-channel-section.tsx @@ -0,0 +1,368 @@ +import { PencilSquare, Trash } from "@medusajs/icons" +import { PublishableApiKey, SalesChannel } from "@medusajs/medusa" +import { + Button, + Checkbox, + CommandBar, + Container, + Heading, + StatusBadge, + Table, + clx, + usePrompt, +} from "@medusajs/ui" +import { + PaginationState, + RowSelectionState, + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { + useAdminPublishableApiKeySalesChannels, + useAdminRemovePublishableKeySalesChannelsBatch, +} from "medusa-react" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Link, useNavigate } from "react-router-dom" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { + NoRecords, + NoResults, +} from "../../../../../components/common/empty-table-content" +import { Query } from "../../../../../components/filtering/query" +import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination" +import { useQueryParams } from "../../../../../hooks/use-query-params" + +type ApiKeySalesChannelSectionProps = { + apiKey: PublishableApiKey +} + +const PAGE_SIZE = 10 + +export const ApiKeySalesChannelSection = ({ + apiKey, +}: ApiKeySalesChannelSectionProps) => { + const { t } = useTranslation() + const navigate = useNavigate() + const prompt = usePrompt() + + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ) + + const [rowSelection, setRowSelection] = useState({}) + + const params = useQueryParams(["q"]) + const { sales_channels, isLoading, isError, error } = + useAdminPublishableApiKeySalesChannels( + apiKey.id, + { + ...params, + }, + { + keepPreviousData: true, + } + ) + + const count = sales_channels?.length || 0 + + const columns = useColumns() + + const table = useReactTable({ + data: sales_channels ?? [], + columns, + pageCount: Math.ceil(count / PAGE_SIZE), + state: { + pagination, + rowSelection, + }, + getRowId: (row) => row.id, + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + meta: { + apiKey: apiKey.id, + }, + }) + + const { mutateAsync } = useAdminRemovePublishableKeySalesChannelsBatch( + apiKey.id + ) + + const handleRemove = async () => { + const keys = Object.keys(rowSelection).filter((k) => rowSelection[k]) + + const res = await prompt({ + title: t("general.areYouSure"), + description: t("apiKeyManagement.removeSalesChannelsWarning", { + count: keys.length, + }), + confirmText: t("general.continue"), + cancelText: t("general.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync( + { + sales_channel_ids: keys.map((k) => ({ id: k })), + }, + { + onSuccess: () => { + setRowSelection({}) + }, + } + ) + } + + const noRecords = !isLoading && !sales_channels?.length && !params.q + + if (isError) { + throw error + } + + return ( + +
+ {t("salesChannels.domain")} + +
+ {!noRecords && ( +
+
+
+ +
+
+ )} + {noRecords ? ( + + ) : ( +
+ {!isLoading && sales_channels?.length !== 0 ? ( + + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + navigate(`/settings/sales-channels/${row.original.id}`) + } + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+ ) : ( + + )} + + + + + {t("general.countSelected", { + count: Object.keys(rowSelection).length, + })} + + + + + +
+ )} +
+ ) +} + +const SalesChannelActions = ({ + salesChannel, + apiKey, +}: { + salesChannel: SalesChannel + apiKey: string +}) => { + const { t } = useTranslation() + const prompt = usePrompt() + + const { mutateAsync } = useAdminRemovePublishableKeySalesChannelsBatch(apiKey) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("apiKeyManagement.removeSalesChannelWarning"), + confirmText: t("general.delete"), + cancelText: t("general.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync({ + sales_channel_ids: [{ id: salesChannel.id }], + }) + } + + return ( + , + label: t("general.edit"), + to: `/settings/sales-channels/${salesChannel.id}/edit`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("general.delete"), + onClick: handleDelete, + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + columnHelper.accessor("name", { + header: t("fields.name"), + cell: ({ getValue }) => getValue(), + }), + columnHelper.accessor("description", { + header: t("fields.description"), + cell: ({ getValue }) => getValue(), + }), + columnHelper.accessor("is_disabled", { + header: t("fields.status"), + cell: ({ getValue }) => { + const value = getValue() + return ( +
+ + {value ? t("general.disabled") : t("general.enabled")} + +
+ ) + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row, table }) => { + const { apiKey } = table.options.meta as { + apiKey: string + } + + return ( + + ) + }, + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/index.ts b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/index.ts new file mode 100644 index 0000000000..a9b2850391 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/components/api-key-sales-channel-section/index.ts @@ -0,0 +1 @@ +export * from "./api-key-sales-channel-section" diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/index.ts b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/index.ts index 2c193a6bd8..710a7b5175 100644 --- a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/index.ts +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/index.ts @@ -1 +1,2 @@ export { ApiKeyManagementDetail as Component } from "./api-key-management-detail" +export { apiKeyLoader as loader } from "./loader" diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/loader.ts b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/loader.ts new file mode 100644 index 0000000000..66034bcebb --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-detail/loader.ts @@ -0,0 +1,22 @@ +import { AdminPublishableApiKeysRes } from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { adminProductKeys } from "medusa-react" +import { LoaderFunctionArgs } from "react-router-dom" + +import { medusa, queryClient } from "../../../lib/medusa" + +const apiKeyDetailQuery = (id: string) => ({ + queryKey: adminProductKeys.detail(id), + queryFn: async () => medusa.admin.publishableApiKeys.retrieve(id), +}) + +export const apiKeyLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = apiKeyDetailQuery(id!) + + return ( + queryClient.getQueryData>( + query.queryKey + ) ?? (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/api-key-management-edit.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/api-key-management-edit.tsx index 5c4125b5dd..61c1fcaeb5 100644 --- a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/api-key-management-edit.tsx +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/api-key-management-edit.tsx @@ -1,12 +1,40 @@ -import { Drawer } from "@medusajs/ui" +import { Drawer, Heading } from "@medusajs/ui" +import { useAdminPublishableApiKey } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" import { useRouteModalState } from "../../../hooks/use-route-modal-state" +import { EditApiKeyForm } from "./components/edit-api-key-form" export const ApiKeyManagementEdit = () => { - const [open, onOpenChange] = useRouteModalState() + const [open, onOpenChange, subscribe] = useRouteModalState() + const { id } = useParams() + const { t } = useTranslation() + + const { publishable_api_key, isLoading, isError, error } = + useAdminPublishableApiKey(id!) + + const handleSuccessfulSubmit = () => { + onOpenChange(false, true) + } + + if (isError) { + throw error + } return ( - + + + {t("apiKeyManagement.editKey")} + + {!isLoading && publishable_api_key && ( + + )} + ) } diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/components/edit-api-key-form/edit-api-key-form.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/components/edit-api-key-form/edit-api-key-form.tsx new file mode 100644 index 0000000000..0a061c564c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/components/edit-api-key-form/edit-api-key-form.tsx @@ -0,0 +1,90 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import type { PublishableApiKey } from "@medusajs/medusa" +import { Button, Drawer, Input } from "@medusajs/ui" +import { useAdminUpdatePublishableApiKey } from "medusa-react" +import { useEffect } from "react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" +import { Form } from "../../../../../components/common/form" + +type EditApiKeyFormProps = { + apiKey: PublishableApiKey + onSuccessfulSubmit: () => void + subscribe: (state: boolean) => void +} + +const EditApiKeySchema = zod.object({ + title: zod.string().min(1), +}) + +export const EditApiKeyForm = ({ + apiKey, + onSuccessfulSubmit, + subscribe, +}: EditApiKeyFormProps) => { + const { t } = useTranslation() + + const form = useForm>({ + defaultValues: { + title: apiKey.title, + }, + resolver: zodResolver(EditApiKeySchema), + }) + + const { + formState: { isDirty }, + } = form + + useEffect(() => { + subscribe(isDirty) + }, [isDirty]) + + const { mutateAsync, isLoading } = useAdminUpdatePublishableApiKey(apiKey.id) + + const handleSubmit = form.handleSubmit(async (data) => { + await mutateAsync(data, { + onSuccess: () => { + onSuccessfulSubmit() + }, + }) + }) + + return ( +
+ + +
+ { + return ( + + {t("fields.title")} + + + + + + ) + }} + /> +
+
+ +
+ + + + +
+
+
+ + ) +} diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/components/edit-api-key-form/index.ts b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/components/edit-api-key-form/index.ts new file mode 100644 index 0000000000..441fcefa9f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-edit/components/edit-api-key-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-api-key-form" diff --git a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-list/components/api-key-management-list-table/api-key-management-list-table.tsx b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-list/components/api-key-management-list-table/api-key-management-list-table.tsx index 4edd5e6a1c..4effd24a8c 100644 --- a/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-list/components/api-key-management-list-table/api-key-management-list-table.tsx +++ b/packages/admin-next/dashboard/src/routes/api-key-management/api-key-management-list/components/api-key-management-list-table/api-key-management-list-table.tsx @@ -1,6 +1,16 @@ import { PencilSquare, Trash, XCircle } from "@medusajs/icons" import { PublishableApiKey } from "@medusajs/medusa" -import { Button, Container, Heading, Table, clx, usePrompt } from "@medusajs/ui" +import { + Button, + Container, + Copy, + Heading, + StatusBadge, + Table, + Text, + clx, + usePrompt, +} from "@medusajs/ui" import { PaginationState, RowSelectionState, @@ -9,6 +19,7 @@ import { getCoreRowModel, useReactTable, } from "@tanstack/react-table" +import { format } from "date-fns" import { useAdminDeletePublishableApiKey, useAdminPublishableApiKeys, @@ -71,7 +82,7 @@ export const ApiKeyManagementListTable = () => { } return ( - +
{t("apiKeyManagement.domain")} @@ -89,7 +100,7 @@ export const ApiKeyManagementListTable = () => { return ( {headerGroup.headers.map((header) => { return ( @@ -247,7 +258,45 @@ const useColumns = () => { }), columnHelper.accessor("id", { header: "Key", - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => { + const token = getValue() + + return ( +
e.stopPropagation()} + > + + {token} + + +
+ ) + }, + }), + columnHelper.accessor("revoked_at", { + header: t("fields.status"), + cell: ({ getValue }) => { + const revokedAt = getValue() + + return ( + + {revokedAt ? t("general.revoked") : t("general.active")} + + ) + }, + }), + columnHelper.accessor("created_at", { + header: t("fields.created"), + cell: ({ getValue }) => { + const date = getValue() + + return format(new Date(date), "dd MMM, yyyy") + }, }), columnHelper.display({ id: "actions", diff --git a/packages/admin-next/dashboard/src/routes/locations/location-detail/components/location-sales-channel-section/location-sales-channel-section.tsx b/packages/admin-next/dashboard/src/routes/locations/location-detail/components/location-sales-channel-section/location-sales-channel-section.tsx index cf0e175299..deeafce0ef 100644 --- a/packages/admin-next/dashboard/src/routes/locations/location-detail/components/location-sales-channel-section/location-sales-channel-section.tsx +++ b/packages/admin-next/dashboard/src/routes/locations/location-detail/components/location-sales-channel-section/location-sales-channel-section.tsx @@ -29,7 +29,7 @@ type LocationSalesChannelSectionProps = { location: StockLocationExpandedDTO } -const PAGE_SIZE = 20 +const PAGE_SIZE = 10 export const LocationSalesChannelSection = ({ location, diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/index.ts b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/index.ts index 29e5a16bcc..8d5a59045f 100644 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/index.ts +++ b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/index.ts @@ -1 +1,2 @@ +export { salesChannelLoader as loader } from "./loader" export { SalesChannelDetail as Component } from "./sales-channel-detail" diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/loader.ts b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/loader.ts new file mode 100644 index 0000000000..5208c2e065 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/loader.ts @@ -0,0 +1,21 @@ +import { AdminSalesChannelsRes } from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { adminProductKeys } from "medusa-react" +import { LoaderFunctionArgs } from "react-router-dom" + +import { medusa, queryClient } from "../../../lib/medusa" + +const salesChannelDetailQuery = (id: string) => ({ + queryKey: adminProductKeys.detail(id), + queryFn: async () => medusa.admin.salesChannels.retrieve(id), +}) + +export const salesChannelLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = salesChannelDetailQuery(id!) + + return ( + queryClient.getQueryData>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx index a8250eb963..dd74f83136 100644 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx @@ -1,13 +1,20 @@ import { useAdminSalesChannel } from "medusa-react" -import { Outlet, useParams } from "react-router-dom" +import { Outlet, useLoaderData, useParams } from "react-router-dom" import { JsonViewSection } from "../../../components/common/json-view-section" import { SalesChannelGeneralSection } from "./components/sales-channel-general-section" import { SalesChannelProductSection } from "./components/sales-channel-product-section" +import { salesChannelLoader } from "./loader" export const SalesChannelDetail = () => { + const initialData = useLoaderData() as Awaited< + ReturnType + > + const { id } = useParams() - const { sales_channel, isLoading } = useAdminSalesChannel(id!) + const { sales_channel, isLoading } = useAdminSalesChannel(id!, { + initialData, + }) if (isLoading || !sales_channel) { return
Loading...
diff --git a/packages/admin-next/dashboard/src/routes/store/store-detail/components/store-general-section/store-general-section.tsx b/packages/admin-next/dashboard/src/routes/store/store-detail/components/store-general-section/store-general-section.tsx index 73c147547f..362c98141a 100644 --- a/packages/admin-next/dashboard/src/routes/store/store-detail/components/store-general-section/store-general-section.tsx +++ b/packages/admin-next/dashboard/src/routes/store/store-detail/components/store-general-section/store-general-section.tsx @@ -51,11 +51,16 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => { {t("store.swapLinkTemplate")} {store.swap_link_template ? ( - - - {store.swap_link_template} - - +
+ + {store.swap_link_template} + + +
) : ( - @@ -67,11 +72,16 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => { {t("store.paymentLinkTemplate")} {store.payment_link_template ? ( - - - {store.payment_link_template} - - +
+ + {store.payment_link_template} + + +
) : ( - @@ -83,11 +93,16 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => { {t("store.inviteLinkTemplate")} {store.invite_link_template ? ( - - - {store.invite_link_template} - - +
+ + {store.invite_link_template} + + +
) : ( - diff --git a/packages/design-system/ui/src/components/avatar/avatar.tsx b/packages/design-system/ui/src/components/avatar/avatar.tsx index 9828742510..9a9df41d1d 100644 --- a/packages/design-system/ui/src/components/avatar/avatar.tsx +++ b/packages/design-system/ui/src/components/avatar/avatar.tsx @@ -14,18 +14,24 @@ const avatarVariants = cva({ rounded: "rounded-full", }, size: { - xsmall: "h-5 w-5", - small: "h-6 w-6", + "2xsmall": "h-5 w-5", + xsmall: "h-6 w-6", + small: "h-7 w-7", base: "h-8 w-8", large: "h-10 w-10", xlarge: "h-12 w-12", }, }, compoundVariants: [ + { + variant: "squared", + size: "2xsmall", + className: "rounded-md", + }, { variant: "squared", size: "xsmall", - className: "rounded-[4px]", + className: "rounded-md", }, { variant: "squared", @@ -62,14 +68,20 @@ const innerVariants = cva({ rounded: "rounded-full", }, size: { - xsmall: "txt-compact-xsmall-plus h-4 w-4", - small: "txt-compact-xsmall-plus h-5 w-5", + "2xsmall": "txt-compact-xsmall-plus h-4 w-4", + xsmall: "txt-compact-xsmall-plus h-5 w-5", + small: "txt-compact-small-plus h-6 w-6", base: "txt-compact-small-plus h-7 w-7", large: "txt-compact-medium-plus h-9 w-9", xlarge: "txt-compact-large-plus h-11 w-11", }, }, compoundVariants: [ + { + variant: "squared", + size: "2xsmall", + className: "rounded-sm", + }, { variant: "squared", size: "xsmall", diff --git a/packages/design-system/ui/src/components/copy/copy.tsx b/packages/design-system/ui/src/components/copy/copy.tsx index 9eceab6771..0fc5530004 100644 --- a/packages/design-system/ui/src/components/copy/copy.tsx +++ b/packages/design-system/ui/src/components/copy/copy.tsx @@ -2,77 +2,115 @@ import { Tooltip } from "@/components/tooltip" import { clx } from "@/utils/clx" -import { CheckCircleSolid, SquareTwoStack } from "@medusajs/icons" +import { + CheckCircleMiniSolid, + CheckCircleSolid, + SquareTwoStack, + SquareTwoStackMini, +} from "@medusajs/icons" import { Slot } from "@radix-ui/react-slot" import copy from "copy-to-clipboard" import React, { useState } from "react" type CopyProps = React.HTMLAttributes & { content: string + variant?: "mini" | "default" | null asChild?: boolean } /** * This component is based on the `button` element and supports all of its props */ -const Copy = React.forwardRef< - HTMLButtonElement, - CopyProps ->(({ - children, - className, - /** - * The content to copy. - */ - content, - /** - * Whether to remove the wrapper `button` element and use the - * passed child element instead. - */ - asChild = false, - ...props - }: CopyProps, ref) => { - const [done, setDone] = useState(false) - const [open, setOpen] = useState(false) - const [text, setText] = useState("Copy") +const Copy = React.forwardRef( + ( + { + children, + className, + /** + * The content to copy. + */ + content, + /** + * The variant of the copy button. + */ + variant = "default", + /** + * Whether to remove the wrapper `button` element and use the + * passed child element instead. + */ + asChild = false, + ...props + }: CopyProps, + ref + ) => { + const [done, setDone] = useState(false) + const [open, setOpen] = useState(false) + const [text, setText] = useState("Copy") - const copyToClipboard = () => { - setDone(true) - copy(content) + const copyToClipboard = ( + e: + | React.MouseEvent + | React.MouseEvent + ) => { + e.stopPropagation() - setTimeout(() => { - setDone(false) - }, 2000) - } + setDone(true) + copy(content) - React.useEffect(() => { - if (done) { - setText("Copied") - return + setTimeout(() => { + setDone(false) + }, 2000) } - setTimeout(() => { - setText("Copy") - }, 500) - }, [done]) + React.useEffect(() => { + if (done) { + setText("Copied") + return + } - const Component = asChild ? Slot : "button" + setTimeout(() => { + setText("Copy") + }, 500) + }, [done]) - return ( - - - {children ? children : done ? : } - - - ) -}) + const isDefaultVariant = ( + variant?: string | null + ): variant is "default" => { + return variant === "default" + } + + const isDefault = isDefaultVariant(variant) + + const Component = asChild ? Slot : "button" + + return ( + + + {children ? ( + children + ) : done ? ( + isDefault ? ( + + ) : ( + + ) + ) : isDefault ? ( + + ) : ( + + )} + + + ) + } +) Copy.displayName = "Copy" export { Copy } diff --git a/packages/design-system/ui/src/components/status-badge/status-badge.tsx b/packages/design-system/ui/src/components/status-badge/status-badge.tsx index 6f7af3c591..80a955db82 100644 --- a/packages/design-system/ui/src/components/status-badge/status-badge.tsx +++ b/packages/design-system/ui/src/components/status-badge/status-badge.tsx @@ -1,11 +1,28 @@ import * as React from "react" import { clx } from "@/utils/clx" +import { VariantProps, cva } from "cva" + +const statusBadgeVariants = cva({ + base: "flex items-center justify-center w-5 h-[18px] [&_div]:w-2 [&_div]:h-2 [&_div]:rounded-sm", + variants: { + color: { + green: "[&_div]:bg-ui-tag-green-icon", + red: "[&_div]:bg-ui-tag-red-icon", + orange: "[&_div]:bg-ui-tag-orange-icon", + blue: "[&_div]:bg-ui-tag-blue-icon", + purple: "[&_div]:bg-ui-tag-purple-icon", + grey: "[&_div]:bg-ui-tag-neutral-icon", + }, + }, + defaultVariants: { + color: "grey", + }, +}) interface StatusBadgeProps - extends Omit, "color"> { - color?: "green" | "red" | "blue" | "orange" | "grey" | "purple" -} + extends Omit, "color">, + VariantProps {} /** * This component is based on the span element and supports all of its props @@ -27,31 +44,14 @@ const StatusBadge = React.forwardRef( - - - - - +
+
+
{children} )