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
2024-06-18 16:46:32 +02:00
committed by GitHub
parent 288e41856b
commit d5c5628ffc
9 changed files with 209 additions and 158 deletions

View File

@@ -30,6 +30,7 @@ export const CreateCategoryForm = ({
const [activeTab, setActiveTab] = useState<Tab>(Tab.DETAILS)
const [validDetails, setValidDetails] = useState(false)
const [shouldFreeze, setShouldFreeze] = useState(false)
const form = useForm<CreateCategorySchema>({
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 = ({
<CreateCategoryDetails form={form} />
</ProgressTabs.Content>
<ProgressTabs.Content value={Tab.ORGANIZE}>
<CreateCategoryNesting form={form} />
<CreateCategoryNesting form={form} shouldFreeze={shouldFreeze} />
</ProgressTabs.Content>
</RouteFocusModal.Body>
</form>

View File

@@ -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<CreateCategorySchema>
shouldFreeze?: boolean
}
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 } =
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 (
<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}
enableDrag={(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
}

View File

@@ -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 (
<RouteFocusModal>
<OrganizeCategoryForm categoryId={id} />
<OrganizeCategoryForm />
</RouteFocusModal>
)
}

View File

@@ -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<FetchError | null>(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

View File

@@ -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={<DragHandle />}
renderCollapseIcon={({ isCollapsed }) => {
return <CollapseHandler isCollapsed={isCollapsed} />
}}
@@ -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 = (
<div
onDrag={() => 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,
}
)}
>
<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) => (
<div key={`offset_${i}`} role="presentation" className="h-7 w-7" />
))}
@@ -198,7 +200,6 @@ export const CategoryBranch = ({
<Text size="small" leading="compact">
{item.name}
</Text>
<Text>{item.rank}</Text>
{isNew && (
<Badge size="2xsmall" color="blue">
{t("categories.fields.new.label")}
@@ -211,14 +212,6 @@ export const CategoryBranch = ({
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 = () => {
return (
<div className="bg-ui-bg-base flex h-12 animate-pulse items-center border-b px-6 py-2.5" />

View File

@@ -1,2 +1 @@
export * from "./category-tree"
export * from "./types"

View File

@@ -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 {

View File

@@ -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<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
}