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.
This commit is contained in:
Kasper Fabricius Kristensen authored and GitHub committed 2024-06-18 14:46:32 +00:00
1 parent 288e41856b
commit d5c5628ffc
9 files changed
+209 -158

No files matched your search

@@ -30,6 +30,7 @@ export const CreateCategoryForm = ({
const [activeTab, setActiveTab] = useState<Tab>(Tab.DETAILS) const [activeTab, setActiveTab] = useState<Tab>(Tab.DETAILS)
const [validDetails, setValidDetails] = useState(false) const [validDetails, setValidDetails] = useState(false)
const [shouldFreeze, setShouldFreeze] = useState(false)
const form = useForm<CreateCategorySchema>({ const form = useForm<CreateCategorySchema>({
defaultValues: { defaultValues: {
@@ -79,6 +80,8 @@ export const CreateCategoryForm = ({
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
const { visibility, status, parent_category_id, rank, ...rest } = data const { visibility, status, parent_category_id, rank, ...rest } = data
setShouldFreeze(true)
mutateAsync( mutateAsync(
{ {
...rest, ...rest,
@@ -105,6 +108,8 @@ export const CreateCategoryForm = ({
dismissable: true, dismissable: true,
dismissLabel: t("actions.close"), dismissLabel: t("actions.close"),
}) })
setShouldFreeze(false)
}, },
} }
) )
@@ -181,7 +186,7 @@ export const CreateCategoryForm = ({
<CreateCategoryDetails form={form} /> <CreateCategoryDetails form={form} />
</ProgressTabs.Content> </ProgressTabs.Content>
<ProgressTabs.Content value={Tab.ORGANIZE}> <ProgressTabs.Content value={Tab.ORGANIZE}>
<CreateCategoryNesting form={form} /> <CreateCategoryNesting form={form} shouldFreeze={shouldFreeze} />
</ProgressTabs.Content> </ProgressTabs.Content>
</RouteFocusModal.Body> </RouteFocusModal.Body>
</form> </form>
@@ -1,19 +1,24 @@
import { useMemo } from "react" import { useMemo, useState } from "react"
import { UseFormReturn, useWatch } from "react-hook-form" import { UseFormReturn, useWatch } from "react-hook-form"
import { useProductCategories } from "../../../../../hooks/api/categories" import { useProductCategories } from "../../../../../hooks/api/categories"
import { import { CategoryTree } from "../../../common/components/category-tree"
CategoryTree, import { CategoryTreeItem } from "../../../common/types"
CategoryTreeItem, import { insertCategoryTreeItem } from "../../../common/utils"
} from "../../../common/components/category-tree"
import { CreateCategorySchema } from "./schema" import { CreateCategorySchema } from "./schema"
type CreateCategoryNestingProps = { type CreateCategoryNestingProps = {
form: UseFormReturn<CreateCategorySchema> form: UseFormReturn<CreateCategorySchema>
shouldFreeze?: boolean
} }
const ID = "new-item" const ID = "new-item"
export const CreateCategoryNesting = ({ form }: CreateCategoryNestingProps) => { export const CreateCategoryNesting = ({
form,
shouldFreeze,
}: CreateCategoryNestingProps) => {
const [snapshot, setSnapshot] = useState<CategoryTreeItem[]>([])
const { product_categories, isPending, isError, error } = const { product_categories, isPending, isError, error } =
useProductCategories( useProductCategories(
{ {
@@ -48,24 +53,27 @@ export const CreateCategoryNesting = ({ form }: CreateCategoryNestingProps) => {
name: watchedName, name: watchedName,
parent_category_id: parentCategoryId, parent_category_id: parentCategoryId,
rank: watchedRank, rank: watchedRank,
category_children: null,
} }
console.log("inserting", temp)
return insertCategoryTreeItem(product_categories ?? [], temp) return insertCategoryTreeItem(product_categories ?? [], temp)
}, [product_categories, watchedName, parentCategoryId, watchedRank]) }, [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, { form.setValue("parent_category_id", parent_category_id, {
shouldDirty: true, shouldDirty: true,
shouldTouch: true, shouldTouch: true,
}) })
console.log("rank", rank)
form.setValue("rank", rank, { form.setValue("rank", rank, {
shouldDirty: true, shouldDirty: true,
shouldTouch: true, shouldTouch: true,
}) })
setSnapshot(list)
} }
if (isError) { if (isError) {
@@ -74,7 +82,8 @@ export const CreateCategoryNesting = ({ form }: CreateCategoryNestingProps) => {
return ( return (
<CategoryTree <CategoryTree
value={value} // When we submit the form we want to freeze the rendered tree to prevent flickering during the exit animation
value={shouldFreeze ? snapshot : value}
onChange={handleChange} onChange={handleChange}
enableDrag={(i) => i.id === ID} enableDrag={(i) => i.id === ID}
showBadge={(i) => 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, "category_children">
): CategoryTreeItem[] => {
const seen = new Set<string>()
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
}
@@ -1,14 +1,10 @@
import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal" import { RouteFocusModal } from "../../../components/route-modal"
import { OrganizeCategoryForm } from "./components/organize-category-form/organize-category-form" 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 = () => { export const CategoryOrganize = () => {
const { id } = useParams()
return ( return (
<RouteFocusModal> <RouteFocusModal>
<OrganizeCategoryForm categoryId={id} /> <OrganizeCategoryForm />
</RouteFocusModal> </RouteFocusModal>
) )
} }
@@ -1,8 +1,10 @@
import { keepPreviousData } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { Spinner } from "@medusajs/icons" import { Spinner } from "@medusajs/icons"
import { FetchError } from "@medusajs/js-sdk" 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 { RouteFocusModal } from "../../../../../components/route-modal"
import { import {
categoriesQueryKeys, categoriesQueryKeys,
@@ -10,66 +12,89 @@ import {
} from "../../../../../hooks/api/categories" } from "../../../../../hooks/api/categories"
import { sdk } from "../../../../../lib/client" import { sdk } from "../../../../../lib/client"
import { queryClient } from "../../../../../lib/query-client" import { queryClient } from "../../../../../lib/query-client"
import { import { CategoryTree } from "../../../common/components/category-tree"
CategoryTree, import { CategoryTreeItem } from "../../../common/types"
CategoryTreeItem,
} from "../../../common/components/category-tree"
type OrganizeCategoryFormProps = { const QUERY = {
categoryId?: string 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 = () => {
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<FetchError | null>(null)
const { const {
product_categories, product_categories,
isPending, isPending,
isError, isError,
error: fetchError, error: fetchError,
} = useProductCategories( } = useProductCategories(QUERY)
{
fields: "id,name,parent_category_id,rank,*category_children",
parent_category_id: "null",
include_descendants_tree: true,
limit: 9999,
},
{
placeholderData: keepPreviousData,
}
)
const handleRankChange = async (value: CategoryTreeItem) => { const { mutateAsync, isPending: isMutating } = useMutation({
setIsLoading(true) mutationFn: async ({
setError(null) value,
}: {
await sdk.admin.productCategory value: CategoryTreeItem
.update(value.id, { arr: CategoryTreeItem[]
}) => {
await sdk.admin.productCategory.update(value.id, {
rank: value.rank ?? 0, rank: value.rank ?? 0,
parent_category_id: value.parent_category_id, parent_category_id: value.parent_category_id,
}) })
.then(async () => { },
await queryClient.invalidateQueries({ onMutate: async (update) => {
queryKey: categoriesQueryKeys.lists(), // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
}) await queryClient.cancelQueries({
await queryClient.invalidateQueries({ queryKey: categoriesQueryKeys.list(QUERY),
queryKey: categoriesQueryKeys.detail(value.id),
})
}) })
.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) { if (isError) {
throw fetchError throw fetchError
@@ -14,8 +14,8 @@ import Nestable from "react-nestable"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import "react-nestable/dist/styles/index.css" import "react-nestable/dist/styles/index.css"
import { CategoryTreeItem } from "../../types"
import "./styles.css" import "./styles.css"
import { CategoryTreeItem } from "./types"
type CategoryTreeProps = { type CategoryTreeProps = {
value: CategoryTreeItem[] value: CategoryTreeItem[]
@@ -119,7 +119,6 @@ export const CategoryTree = ({
/> />
) )
}} }}
handler={<DragHandle />}
renderCollapseIcon={({ isCollapsed }) => { renderCollapseIcon={({ isCollapsed }) => {
return <CollapseHandler isCollapsed={isCollapsed} /> return <CollapseHandler isCollapsed={isCollapsed} />
}} }}
@@ -155,7 +154,7 @@ type CategoryBranchProps = {
isEnabled: boolean isEnabled: boolean
isNew?: boolean isNew?: boolean
collapseIcon: ReactNode collapseIcon: ReactNode
handler: ReactNode handler?: ReactNode
} }
export const CategoryBranch = ({ export const CategoryBranch = ({
@@ -164,7 +163,6 @@ export const CategoryBranch = ({
isEnabled, isEnabled,
isNew = false, isNew = false,
collapseIcon, collapseIcon,
handler,
}: CategoryBranchProps) => { }: CategoryBranchProps) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -172,15 +170,19 @@ export const CategoryBranch = ({
const Component = ( const Component = (
<div <div
onDrag={() => console.log("dragging")}
data-disabled={!isEnabled} data-disabled={!isEnabled}
className={clx( 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,
} }
)} )}
> >
<div>{handler}</div> <div className="flex h-7 w-7 items-center justify-center">
<DotsSix className="text-ui-fg-subtle" />
</div>
{Array.from({ length: depth }).map((_, i) => ( {Array.from({ length: depth }).map((_, i) => (
<div key={`offset_${i}`} role="presentation" className="h-7 w-7" /> <div key={`offset_${i}`} role="presentation" className="h-7 w-7" />
))} ))}
@@ -198,7 +200,6 @@ export const CategoryBranch = ({
<Text size="small" leading="compact"> <Text size="small" leading="compact">
{item.name} {item.name}
</Text> </Text>
<Text>{item.rank}</Text>
{isNew && ( {isNew && (
<Badge size="2xsmall" color="blue"> <Badge size="2xsmall" color="blue">
{t("categories.fields.new.label")} {t("categories.fields.new.label")}
@@ -211,14 +212,6 @@ export const CategoryBranch = ({
return Component return Component
} }
const DragHandle = () => {
return (
<div className="flex h-7 w-7 cursor-grab items-center justify-center active:cursor-grabbing group-data-[disabled=true]:cursor-not-allowed">
<DotsSix className="text-ui-fg-subtle" />
</div>
)
}
const CategoryLeafPlaceholder = () => { const CategoryLeafPlaceholder = () => {
return ( return (
<div className="bg-ui-bg-base flex h-12 animate-pulse items-center border-b px-6 py-2.5" /> <div className="bg-ui-bg-base flex h-12 animate-pulse items-center border-b px-6 py-2.5" />
@@ -1,2 +1 @@
export * from "./category-tree" export * from "./category-tree"
export * from "./types"
@@ -9,28 +9,39 @@
left: 0 !important; left: 0 !important;
right: 0 !important; right: 0 !important;
bottom: 0 !important; bottom: 0 !important;
background: transparent !important; background: var(--bg-base-hover) !important;
border-radius: 0 !important; border-radius: 0 !important;
transition: 0.3s all !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 * { .nestable-item.is-dragging * {
height: 48px !important; height: 48px !important;
min-height: 48px !important; min-height: 48px !important;
max-height: 48px !important; max-height: 48px !important;
border-radius: 6 !important;
overflow: hidden !important; overflow: hidden !important;
background: var(--bg-base) !important; background: var(--bg-base) !important;
box-shadow: var(--elevation-card-hover) !important; box-shadow: var(--elevation-card-hover) !important;
z-index: 1000 !important; z-index: 1000 !important;
} }
.nestable-drag-layer > .nestable-list > .nestable-item-copy-new-item { .nestable-drag-layer > .nestable-list {
box-shadow: var(--elevation-card-hover) !important; box-shadow: var(--elevation-flyout) !important;
border-radius: 6 !important; border-radius: 6px !important;
overflow: hidden !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 { .nestable-list {
@@ -1,6 +1,8 @@
import { AdminProductCategoryResponse } from "@medusajs/types" import { AdminProductCategoryResponse } from "@medusajs/types"
import { TFunction } from "i18next" import { TFunction } from "i18next"
import { CategoryTreeItem } from "./types"
export function getIsActiveProps( export function getIsActiveProps(
isActive: boolean, isActive: boolean,
t: TFunction t: TFunction
@@ -69,3 +71,87 @@ export function getCategoryChildren(
name: child.name, name: child.name,
})) }))
} }
export const insertCategoryTreeItem = (
categories: CategoryTreeItem[],
newItem: CategoryTreeItem
): CategoryTreeItem[] => {
const seen = new Set<string>()
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
}