From d5c5628ffc015286a359772d046eafff5ce7e539 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 18 Jun 2024 16:46:32 +0200 Subject: [PATCH] feat(dashboard): Add Optimistic UI to category rank form + style updates (#7747) **What** - Makes rank updates optimistic, meaning that we override the local state (ranking) with what we expect the outcome of the request to be. If a request fails then we revert to the last known server state. - Updates the style of dragged items. - Fixes an issue where the tree would flicker when submitting the create form. --- .../create-category-form.tsx | 7 +- .../create-category-nesting.tsx | 104 +++------------- .../category-organize/category-organize.tsx | 6 +- .../organize-category-form.tsx | 115 +++++++++++------- .../category-tree/category-tree.tsx | 25 ++-- .../common/components/category-tree/index.ts | 1 - .../components/category-tree/styles.css | 23 +++- .../{components/category-tree => }/types.ts | 0 .../src/routes/categories/common/utils.ts | 86 +++++++++++++ 9 files changed, 209 insertions(+), 158 deletions(-) rename packages/admin-next/dashboard/src/routes/categories/common/{components/category-tree => }/types.ts (100%) diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx index 0cf86d5dda..9e949a529a 100644 --- a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx +++ b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx @@ -30,6 +30,7 @@ export const CreateCategoryForm = ({ const [activeTab, setActiveTab] = useState(Tab.DETAILS) const [validDetails, setValidDetails] = useState(false) + const [shouldFreeze, setShouldFreeze] = useState(false) const form = useForm({ defaultValues: { @@ -79,6 +80,8 @@ export const CreateCategoryForm = ({ const handleSubmit = form.handleSubmit((data) => { const { visibility, status, parent_category_id, rank, ...rest } = data + setShouldFreeze(true) + mutateAsync( { ...rest, @@ -105,6 +108,8 @@ export const CreateCategoryForm = ({ dismissable: true, dismissLabel: t("actions.close"), }) + + setShouldFreeze(false) }, } ) @@ -181,7 +186,7 @@ export const CreateCategoryForm = ({ - + diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx index 4c7dc8aeec..de79dae30e 100644 --- a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx +++ b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx @@ -1,19 +1,24 @@ -import { useMemo } from "react" +import { useMemo, useState } from "react" import { UseFormReturn, useWatch } from "react-hook-form" import { useProductCategories } from "../../../../../hooks/api/categories" -import { - CategoryTree, - CategoryTreeItem, -} from "../../../common/components/category-tree" +import { CategoryTree } from "../../../common/components/category-tree" +import { CategoryTreeItem } from "../../../common/types" +import { insertCategoryTreeItem } from "../../../common/utils" import { CreateCategorySchema } from "./schema" type CreateCategoryNestingProps = { form: UseFormReturn + shouldFreeze?: boolean } const ID = "new-item" -export const CreateCategoryNesting = ({ form }: CreateCategoryNestingProps) => { +export const CreateCategoryNesting = ({ + form, + shouldFreeze, +}: CreateCategoryNestingProps) => { + const [snapshot, setSnapshot] = useState([]) + const { product_categories, isPending, isError, error } = useProductCategories( { @@ -48,24 +53,27 @@ export const CreateCategoryNesting = ({ form }: CreateCategoryNestingProps) => { name: watchedName, parent_category_id: parentCategoryId, rank: watchedRank, + category_children: null, } - console.log("inserting", temp) - return insertCategoryTreeItem(product_categories ?? [], temp) }, [product_categories, watchedName, parentCategoryId, watchedRank]) - const handleChange = ({ parent_category_id, rank }: CategoryTreeItem) => { + const handleChange = ( + { parent_category_id, rank }: CategoryTreeItem, + list: CategoryTreeItem[] + ) => { form.setValue("parent_category_id", parent_category_id, { shouldDirty: true, shouldTouch: true, }) - console.log("rank", rank) form.setValue("rank", rank, { shouldDirty: true, shouldTouch: true, }) + + setSnapshot(list) } if (isError) { @@ -74,7 +82,8 @@ export const CreateCategoryNesting = ({ form }: CreateCategoryNestingProps) => { return ( i.id === ID} showBadge={(i) => i.id === ID} @@ -82,76 +91,3 @@ export const CreateCategoryNesting = ({ form }: CreateCategoryNestingProps) => { /> ) } - -/** - * Since we allow the user to go back and forth between the two steps of the form, - * we need to handle restoring the state of the tree when it re-renders. - */ -const insertCategoryTreeItem = ( - categories: CategoryTreeItem[], - newItem: Omit -): CategoryTreeItem[] => { - const seen = new Set() - - const remove = ( - items: CategoryTreeItem[], - id: string - ): CategoryTreeItem[] => { - const stack = [...items] - const result: CategoryTreeItem[] = [] - - while (stack.length > 0) { - const item = stack.pop()! - if (item.id !== id) { - if (item.category_children) { - item.category_children = remove(item.category_children, id) - } - result.push(item) - } - } - - return result - } - - const insert = (items: CategoryTreeItem[]): CategoryTreeItem[] => { - const stack = [...items] - - while (stack.length > 0) { - const item = stack.pop()! - if (seen.has(item.id)) { - continue // Prevent revisiting the same node - } - seen.add(item.id) - - if (item.id === newItem.parent_category_id) { - if (!item.category_children) { - item.category_children = [] - } - item.category_children.push({ ...newItem, category_children: null }) - item.category_children.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)) - return categories - } - if (item.category_children) { - stack.push(...item.category_children) - } - } - return items - } - - categories = remove(categories, newItem.id) - - if (newItem.parent_category_id === null && newItem.rank === null) { - categories.unshift({ ...newItem, category_children: null }) - } else if (newItem.parent_category_id === null && newItem.rank !== null) { - categories.splice(newItem.rank, 0, { - ...newItem, - category_children: null, - }) - } else { - categories = insert(categories) - } - - categories.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)) - - return categories -} diff --git a/packages/admin-next/dashboard/src/routes/categories/category-organize/category-organize.tsx b/packages/admin-next/dashboard/src/routes/categories/category-organize/category-organize.tsx index 1405f43c8d..6ceb519680 100644 --- a/packages/admin-next/dashboard/src/routes/categories/category-organize/category-organize.tsx +++ b/packages/admin-next/dashboard/src/routes/categories/category-organize/category-organize.tsx @@ -1,14 +1,10 @@ -import { useParams } from "react-router-dom" import { RouteFocusModal } from "../../../components/route-modal" import { OrganizeCategoryForm } from "./components/organize-category-form/organize-category-form" -// TODO: Something around the mpath of categories is bugged out, and using this form breaks your categories. See CORE-2287. export const CategoryOrganize = () => { - const { id } = useParams() - return ( - + ) } diff --git a/packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/organize-category-form.tsx b/packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/organize-category-form.tsx index 028596b5df..fbc738433e 100644 --- a/packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/organize-category-form.tsx +++ b/packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/organize-category-form.tsx @@ -1,8 +1,10 @@ -import { keepPreviousData } from "@tanstack/react-query" +import { useMutation } from "@tanstack/react-query" import { Spinner } from "@medusajs/icons" import { FetchError } from "@medusajs/js-sdk" -import { useState } from "react" +import { HttpTypes } from "@medusajs/types" +import { toast } from "@medusajs/ui" +import { t } from "i18next" import { RouteFocusModal } from "../../../../../components/route-modal" import { categoriesQueryKeys, @@ -10,66 +12,89 @@ import { } from "../../../../../hooks/api/categories" import { sdk } from "../../../../../lib/client" import { queryClient } from "../../../../../lib/query-client" -import { - CategoryTree, - CategoryTreeItem, -} from "../../../common/components/category-tree" +import { CategoryTree } from "../../../common/components/category-tree" +import { CategoryTreeItem } from "../../../common/types" -type OrganizeCategoryFormProps = { - categoryId?: string +const QUERY = { + fields: "id,name,parent_category_id,rank,*category_children", + parent_category_id: "null", + include_descendants_tree: true, + limit: 9999, } -// TODO: Add some focus/highlight state if we enter this form from a specific category. Awaiting design. -export const OrganizeCategoryForm = ({ - categoryId, -}: OrganizeCategoryFormProps) => { - const [isLoading, setIsLoading] = useState(false) - // TODO: Display error message to the user, might be in a toast or in the header. Awaiting design. - const [error, setError] = useState(null) - +export const OrganizeCategoryForm = () => { const { product_categories, isPending, isError, error: fetchError, - } = useProductCategories( - { - fields: "id,name,parent_category_id,rank,*category_children", - parent_category_id: "null", - include_descendants_tree: true, - limit: 9999, - }, - { - placeholderData: keepPreviousData, - } - ) + } = useProductCategories(QUERY) - const handleRankChange = async (value: CategoryTreeItem) => { - setIsLoading(true) - setError(null) - - await sdk.admin.productCategory - .update(value.id, { + const { mutateAsync, isPending: isMutating } = useMutation({ + mutationFn: async ({ + value, + }: { + value: CategoryTreeItem + arr: CategoryTreeItem[] + }) => { + await sdk.admin.productCategory.update(value.id, { rank: value.rank ?? 0, parent_category_id: value.parent_category_id, }) - .then(async () => { - await queryClient.invalidateQueries({ - queryKey: categoriesQueryKeys.lists(), - }) - await queryClient.invalidateQueries({ - queryKey: categoriesQueryKeys.detail(value.id), - }) + }, + onMutate: async (update) => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ + queryKey: categoriesQueryKeys.list(QUERY), }) - .catch((error) => { - setError(error) + + // Snapshot the previous value + const previousValue: + | HttpTypes.AdminProductCategoryListResponse + | undefined = queryClient.getQueryData(categoriesQueryKeys.list(QUERY)) + + const nextValue = { + ...previousValue, + product_categories: update.arr, + } + + queryClient.setQueryData(categoriesQueryKeys.list(QUERY), nextValue) + + return { + previousValue, + } + }, + onError: (error: FetchError, _newValue, context) => { + // Roll back to the previous value + queryClient.setQueryData( + categoriesQueryKeys.list(QUERY), + context?.previousValue + ) + + toast.error(t("general.error"), { + description: error.message, + dismissLabel: t("general.close"), + dismissable: true, }) - .finally(() => { - setIsLoading(false) + }, + onSettled: async (_data, _error, variables) => { + await queryClient.invalidateQueries({ + queryKey: categoriesQueryKeys.lists(), }) + await queryClient.invalidateQueries({ + queryKey: categoriesQueryKeys.detail(variables.value.id), + }) + }, + }) + + const handleRankChange = async ( + value: CategoryTreeItem, + arr: CategoryTreeItem[] + ) => { + await mutateAsync({ value, arr }) } - const loading = isPending || isLoading + const loading = isPending || isMutating if (isError) { throw fetchError diff --git a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/category-tree.tsx b/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/category-tree.tsx index 35a9ec32a3..06682437a7 100644 --- a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/category-tree.tsx +++ b/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/category-tree.tsx @@ -14,8 +14,8 @@ import Nestable from "react-nestable" import { useTranslation } from "react-i18next" import "react-nestable/dist/styles/index.css" +import { CategoryTreeItem } from "../../types" import "./styles.css" -import { CategoryTreeItem } from "./types" type CategoryTreeProps = { value: CategoryTreeItem[] @@ -119,7 +119,6 @@ export const CategoryTree = ({ /> ) }} - handler={} renderCollapseIcon={({ isCollapsed }) => { return }} @@ -155,7 +154,7 @@ type CategoryBranchProps = { isEnabled: boolean isNew?: boolean collapseIcon: ReactNode - handler: ReactNode + handler?: ReactNode } export const CategoryBranch = ({ @@ -164,7 +163,6 @@ export const CategoryBranch = ({ isEnabled, isNew = false, collapseIcon, - handler, }: CategoryBranchProps) => { const { t } = useTranslation() @@ -172,15 +170,19 @@ export const CategoryBranch = ({ const Component = (
console.log("dragging")} data-disabled={!isEnabled} className={clx( - "bg-ui-bg-base hover:bg-ui-bg-base-hover transition-fg group group flex h-12 items-center gap-x-3 border-b px-6 py-2.5", + "bg-ui-bg-base hover:bg-ui-bg-base-hover transition-fg group group flex h-12 cursor-grab items-center gap-x-3 border-b px-6 py-2.5 active:cursor-grabbing", { - "bg-ui-bg-subtle hover:bg-ui-bg-subtle": !isEnabled, + "bg-ui-bg-subtle hover:bg-ui-bg-subtle cursor-not-allowed": + !isEnabled, } )} > -
{handler}
+
+ +
{Array.from({ length: depth }).map((_, i) => (
))} @@ -198,7 +200,6 @@ export const CategoryBranch = ({ {item.name} - {item.rank} {isNew && ( {t("categories.fields.new.label")} @@ -211,14 +212,6 @@ export const CategoryBranch = ({ return Component } -const DragHandle = () => { - return ( -
- -
- ) -} - const CategoryLeafPlaceholder = () => { return (
diff --git a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/index.ts b/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/index.ts index bde36ac033..9f2bfa63df 100644 --- a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/index.ts +++ b/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/index.ts @@ -1,2 +1 @@ export * from "./category-tree" -export * from "./types" diff --git a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/styles.css b/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/styles.css index 6fe69d6b16..1f4effa57c 100644 --- a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/styles.css +++ b/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/styles.css @@ -9,28 +9,39 @@ left: 0 !important; right: 0 !important; bottom: 0 !important; - background: transparent !important; + background: var(--bg-base-hover) !important; border-radius: 0 !important; transition: 0.3s all !important; - border: 1px dashed var(--border-interactive) !important; + border: 0px !important; + border-bottom: 1px solid var(--border-base) !important; } .nestable-item.is-dragging * { height: 48px !important; min-height: 48px !important; max-height: 48px !important; - border-radius: 6 !important; overflow: hidden !important; background: var(--bg-base) !important; box-shadow: var(--elevation-card-hover) !important; z-index: 1000 !important; } -.nestable-drag-layer > .nestable-list > .nestable-item-copy-new-item { - box-shadow: var(--elevation-card-hover) !important; - border-radius: 6 !important; +.nestable-drag-layer > .nestable-list { + box-shadow: var(--elevation-flyout) !important; + border-radius: 6px !important; overflow: hidden !important; + opacity: 0.8 !important; + width: fit-content !important; +} + +.nestable-item, +.nestable-item-copy { + margin: 0 !important; +} + +.nestable-drag-layer > .nestable-list > .nestable-item-copy > div { + border-bottom: 0px !important; } .nestable-list { diff --git a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/types.ts b/packages/admin-next/dashboard/src/routes/categories/common/types.ts similarity index 100% rename from packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/types.ts rename to packages/admin-next/dashboard/src/routes/categories/common/types.ts diff --git a/packages/admin-next/dashboard/src/routes/categories/common/utils.ts b/packages/admin-next/dashboard/src/routes/categories/common/utils.ts index aea36f0888..9cad8d2d7d 100644 --- a/packages/admin-next/dashboard/src/routes/categories/common/utils.ts +++ b/packages/admin-next/dashboard/src/routes/categories/common/utils.ts @@ -1,6 +1,8 @@ import { AdminProductCategoryResponse } from "@medusajs/types" import { TFunction } from "i18next" +import { CategoryTreeItem } from "./types" + export function getIsActiveProps( isActive: boolean, t: TFunction @@ -69,3 +71,87 @@ export function getCategoryChildren( name: child.name, })) } + +export const insertCategoryTreeItem = ( + categories: CategoryTreeItem[], + newItem: CategoryTreeItem +): CategoryTreeItem[] => { + const seen = new Set() + + const remove = ( + items: CategoryTreeItem[], + id: string + ): CategoryTreeItem[] => { + const stack = [...items] + const result: CategoryTreeItem[] = [] + + while (stack.length > 0) { + const item = stack.pop()! + if (item.id !== id) { + if (item.category_children) { + item.category_children = remove(item.category_children, id) + } + result.push(item) + } + } + + return result + } + + const insert = (items: CategoryTreeItem[]): CategoryTreeItem[] => { + const stack = [...items] + + while (stack.length > 0) { + const item = stack.pop()! + if (seen.has(item.id)) { + continue // Prevent revisiting the same node + } + seen.add(item.id) + + if (item.id === newItem.parent_category_id) { + if (!item.category_children) { + item.category_children = [] + } + + if (newItem.rank === null) { + item.category_children.push(newItem) + } else { + item.category_children.splice(newItem.rank, 0, newItem) + } + + item.category_children.forEach((child, index) => { + child.rank = index + }) + + item.category_children.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)) + return categories + } + if (item.category_children) { + stack.push(...item.category_children) + } + } + return items + } + + categories = remove(categories, newItem.id) + + if (newItem.parent_category_id === null && newItem.rank === null) { + categories.unshift(newItem) + + categories.forEach((child, index) => { + child.rank = index + }) + } else if (newItem.parent_category_id === null && newItem.rank !== null) { + categories.splice(newItem.rank, 0, newItem) + + categories.forEach((child, index) => { + child.rank = index + }) + } else { + categories = insert(categories) + } + + categories.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)) + + return categories +}