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:
committed by
GitHub
parent
288e41856b
commit
d5c5628ffc
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./category-tree"
|
||||
export * from "./types"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user