feat: Translations UI (#14217)

* Add Translations route and guard it with feature flag. Empty TranslationsList main component to test route.

* Translation list component

* Add translations namespace to js-sdk

* Translations hook

* Avoid incorrectly throwing when updating and locale not included

* Translations bulk editor component v1

* Add batch method to translations namespace in js-sdk

* Protect translations edit route with feature flag

* Handle reference_id search param

* Replace entity_type entity_id for reference reference_id

* Manage translations from product detail page

* Dynamically resolve base hook for retrieving translations

* Fix navigation from outside settings/translations

* Navigation to bulk editor from product list

* Add Translations to various product module types

* Type useVariants hook

* Handle product module entities translations in bulk editor

* Fix categories issue in datagrid due to column clash

* Translations bulk navigation from remaining entities detail pages

* Add remaining bulk editor navigation for list components. Fix invalidation query for variants

* Expandable text cell v1

* Popover approach

* Add *supported_locales.locale to default fields in stores list endpoint

* Make popover more aligned to excell approach

* Correctly tie the focused cell anchor to popover

* Rework translations main component UI

* Fix link def export

* Swap axis for translations datagrid

* Add original column to translations data grid

* Remove is_default store locale from backend

* Remove ldefault locale from ui

* Type

* Add changeset

* Comments

* Remove unused import

* Add translations to admin product categories endpoint allowed fields

* Default locale removal

* Lazy loading with infinite scroll data grid

* Infinite list hook and implementation for products and variants

* Translation bulk editor lazy loaded datagrid

* Prevent scroll when forcing focus, to avoid scrollTop reset on infinite loading

* Confgiure placeholder data

* Cleanup logs and refactor

* Infinite query hooks for translatable entities

* Batch requests for translation batch endpoint

* Clean up

* Update icon

* Add query param validator in settings endpoint

* Settings endpoint param type

* JS sdk methods for translation settings and statistics

* Retrieve translatable fields and entities dynamically. Remove hardcoded information from tranlations list

* Resolve translation aggregate completion dynamically

* Format label

* Resolve bulk editor header label dynamically

* Include type and collection in translations config

* Avoid showing product option and option values in translatable entities list

* Translations

* Make translations bulk editor content columns wider

* Disable hiding Original column in translations bulk editor

* Adjust translations completion styles

* Fix translations config screen

* Locale selector switcher with conditional locale column rendering

* Batch one locale at a time

* Hooks save actions to footer buttons

* Reset dirty state on save

* Dynamic row heights for translations bulk editor. Replace expandable cell for text cell, with additional isMultiLine config

* Make columns take as much responsive width as possible and divide equally

* more padding to avoid unnecessary horizontal scrollbar

* Update statistics graphs

* Translations

* Statistics graphs translations

* Translation, text sizes and weight in stat graphs

* Conditionally show/hide column visibility dropdown in datagrid

* Allow to pass component to place in DataGrid header and use it in translations bulk editor

* Center text regardless of multiLine config

* Apply full height to datagrid cell regardles of multiSelect config

* Colors and fonts

* Handle key down for text area in text cell

* MultilineCell with special keydown handling

* Rework form schema to match new single locale edit flow

* Update created translations to include id, to avoid duplication issue on subsequent calls

* Handle space key for text cells

* Finish hooking up multiline cell with key and mouse events

* Disable remaining buttons when batch is ongoing

* Style updates

* Update style

* Refactor to make form updates and sync/comparison with server data more comprehensive and robust

* Update styles

* Bars and labels alignment

* Add languages tooltip

* Styles and translation

* Navigation update

* Disable edit translations button when no reference count

* Invert colors

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
Co-authored-by: Adrien de Peretti <adrien.deperetti@gmail.com>
This commit is contained in:
Nicolas Gorga
2025-12-17 09:36:50 -03:00
committed by GitHub
parent c1a5390fc6
commit 3d1330ebb9
69 changed files with 3595 additions and 112 deletions
@@ -4,6 +4,7 @@ import { PropsWithChildren } from "react"
type IconAvatarProps = PropsWithChildren<{
className?: string
size?: "small" | "large" | "xlarge"
variant?: "squared" | "rounded"
}>
/**
@@ -13,6 +14,7 @@ type IconAvatarProps = PropsWithChildren<{
*/
export const IconAvatar = ({
size = "small",
variant = "rounded",
children,
className,
}: IconAvatarProps) => {
@@ -20,6 +22,8 @@ export const IconAvatar = ({
<div
className={clx(
"shadow-borders-base flex size-7 items-center justify-center",
variant === "squared" && "rounded-md",
variant === "rounded" && "rounded-full",
"[&>div]:bg-ui-bg-field [&>div]:text-ui-fg-subtle [&>div]:flex [&>div]:size-6 [&>div]:items-center [&>div]:justify-center",
{
"size-7 rounded-md [&>div]:size-6 [&>div]:rounded-[4px]":
@@ -20,16 +20,20 @@ export const DataGridCellContainer = ({
errors,
rowErrors,
outerComponent,
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
isMultiLine,
}: DataGridCellContainerProps &
DataGridErrorRenderProps<any> & { isMultiLine?: boolean }) => {
const error = get(errors, field)
const hasError = !!error
return (
<div className="group/container relative size-full">
<div className={clx("group/container relative h-full w-full")}>
<div
className={clx(
"bg-ui-bg-base group/cell relative flex size-full items-center gap-x-2 px-4 py-2.5 outline-none",
"bg-ui-bg-base group/cell relative flex h-full w-full gap-x-2 px-4 py-2.5 outline-none",
{
"items-center": !isMultiLine,
"items-start": isMultiLine,
"bg-ui-tag-red-bg text-ui-tag-red-text":
hasError && !isAnchor && !isSelected && !isDragSelected,
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
@@ -54,7 +58,12 @@ export const DataGridCellContainer = ({
)
}}
/>
<div className="relative z-[1] flex size-full items-center justify-center">
<div
className={clx("relative z-[1] flex h-full w-full", {
"items-center justify-center": !isMultiLine,
"items-start": isMultiLine,
})}
>
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
{children}
</RenderChildren>
@@ -0,0 +1,103 @@
import { clx } from "@medusajs/ui"
import { useCallback, useEffect, useRef, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridMultilineCell = <TData, TValue = any>({
context,
}: DataGridCellProps<TData, TValue>) => {
const { field, control, renderProps } = useDataGridCell({
context,
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
return (
<Controller
control={control}
name={field}
render={({ field }) => {
return (
<DataGridCellContainer {...container} {...errorProps} isMultiLine>
<Inner field={field} inputProps={input} />
</DataGridCellContainer>
)
}}
/>
)
}
const Inner = ({
field,
inputProps,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
}) => {
const { onChange: _, onBlur, ref, value, ...rest } = field
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
const [localValue, setLocalValue] = useState(value)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
const combinedRefs = useCombinedRefs(inputRef, ref, textareaRef)
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
// Reset height to 0 to get accurate scrollHeight
textarea.style.height = "0px"
// Set the height to match content (minimum 24px for min visible height)
const newHeight = Math.max(textarea.scrollHeight, 24)
textarea.style.height = `${newHeight}px`
}
}, [])
// Adjust height when value changes
useEffect(() => {
adjustTextareaHeight()
}, [localValue, adjustTextareaHeight])
useEffect(() => {
// Immediate adjustment
adjustTextareaHeight()
// Delayed adjustment to handle any layout shifts
const timeoutId = setTimeout(adjustTextareaHeight, 50)
return () => clearTimeout(timeoutId)
}, [adjustTextareaHeight])
return (
<textarea
className={clx(
"txt-compact-small text-ui-fg-subtle flex w-full cursor-pointer bg-transparent outline-none",
"focus:cursor-text",
"resize-none overflow-hidden py-2"
)}
autoComplete="off"
tabIndex={-1}
value={localValue ?? ""}
onChange={(e) => {
setLocalValue(e.target.value)
adjustTextareaHeight()
}}
ref={combinedRefs}
onBlur={() => {
onBlur()
onInputBlur()
onChange(localValue, value)
}}
{...input}
{...rest}
/>
)
}
@@ -9,24 +9,34 @@ type DataGridReadonlyCellProps<TData, TValue = any> = PropsWithChildren<
DataGridCellProps<TData, TValue>
> & {
color?: "muted" | "normal"
isMultiLine?: boolean
}
export const DataGridReadonlyCell = <TData, TValue = any>({
context,
color = "muted",
children,
isMultiLine = false,
}: DataGridReadonlyCellProps<TData, TValue>) => {
const { rowErrors } = useDataGridCellError({ context })
return (
<div
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none",
"txt-compact-small text-ui-fg-subtle flex w-full cursor-not-allowed justify-between overflow-hidden px-4 py-2.5 outline-none",
color === "muted" && "bg-ui-bg-subtle",
color === "normal" && "bg-ui-bg-base"
color === "normal" && "bg-ui-bg-base",
"h-full items-center"
)}
>
<div className="flex-1 truncate">{children}</div>
<div
className={clx("flex-1", {
truncate: !isMultiLine,
"whitespace-pre-wrap break-words": isMultiLine,
})}
>
{children}
</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
</div>
)
@@ -15,7 +15,11 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual"
import {
VirtualItem,
Virtualizer,
useVirtualizer,
} from "@tanstack/react-virtual"
import React, {
CSSProperties,
ReactNode,
@@ -51,7 +55,7 @@ import { isCellMatch, isSpecialFocusKey } from "../utils"
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues,
TFieldValues extends FieldValues = FieldValues
> {
data?: TData[]
columns: ColumnDef<TData>[]
@@ -60,6 +64,25 @@ export interface DataGridRootProps<
onEditingChange?: (isEditing: boolean) => void
disableInteractions?: boolean
multiColumnSelection?: boolean
showColumnsDropdown?: boolean
/**
* Custom content to render in the header, positioned between the column visibility
* controls and the error/shortcuts section.
*/
headerContent?: ReactNode
/**
* Lazy loading props - when totalRowCount is provided, the grid enters lazy loading mode.
* In this mode, the virtualizer will size based on totalRowCount and trigger onFetchMore
* when the user scrolls near the end of loaded data.
*/
/** Total count of rows for scroll sizing (enables lazy loading mode when provided) */
totalRowCount?: number
/** Called when more data should be fetched */
onFetchMore?: () => void
/** Whether more data is currently being fetched */
isFetchingMore?: boolean
/** Whether there is more data to fetch */
hasNextPage?: boolean
}
const ROW_HEIGHT = 40
@@ -97,7 +120,7 @@ const getCommonPinningStyles = <TData,>(
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues,
TFieldValues extends FieldValues = FieldValues
>({
data = [],
columns,
@@ -106,7 +129,15 @@ export const DataGridRoot = <
onEditingChange,
disableInteractions,
multiColumnSelection = false,
showColumnsDropdown = true,
totalRowCount,
onFetchMore,
isFetchingMore,
hasNextPage,
headerContent,
}: DataGridRootProps<TData, TFieldValues>) => {
// TODO: remove once everything is lazy loaded
const isLazyMode = totalRowCount !== undefined
const containerRef = useRef<HTMLDivElement>(null)
const { redo, undo, execute } = useCommandHistory()
@@ -163,10 +194,18 @@ export const DataGridRoot = <
)
const visibleColumns = grid.getVisibleLeafColumns()
const effectiveRowCount = isLazyMode ? totalRowCount! : visibleRows.length
const rowVirtualizer = useVirtualizer({
count: visibleRows.length,
count: effectiveRowCount,
estimateSize: () => ROW_HEIGHT,
getScrollElement: () => containerRef.current,
// Measure actual row heights for dynamic sizing (disabled in Firefox due to measurement issues). Taken from Tanstack
measureElement:
typeof window !== "undefined" &&
navigator.userAgent.indexOf("Firefox") === -1
? (element) => element?.getBoundingClientRect().height
: undefined,
overscan: 5,
rangeExtractor: (range) => {
const toRender = new Set(
@@ -189,6 +228,76 @@ export const DataGridRoot = <
})
const virtualRows = rowVirtualizer.getVirtualItems()
/**
* Lazy loading scroll detection.
* When the user scrolls near the end of loaded data, trigger onFetchMore.
* We use refs to get latest values in the scroll handler without re-attaching.
*/
const lazyLoadingRefs = useRef({
onFetchMore,
hasNextPage,
isFetchingMore,
loadedRowCount: visibleRows.length,
})
// Keep refs updated
useEffect(() => {
lazyLoadingRefs.current = {
onFetchMore,
hasNextPage,
isFetchingMore,
loadedRowCount: visibleRows.length,
}
}, [onFetchMore, hasNextPage, isFetchingMore, visibleRows.length])
const hasData = visibleRows.length > 0
const handleScroll = useCallback(() => {
const { onFetchMore, hasNextPage, isFetchingMore, loadedRowCount } =
lazyLoadingRefs.current
if (!onFetchMore || !hasNextPage || isFetchingMore) {
return
}
const scrollElement = containerRef.current
const { scrollTop, clientHeight } = scrollElement!
const loadedHeight = loadedRowCount * ROW_HEIGHT
const viewportBottom = scrollTop + clientHeight
const fetchThreshold = loadedHeight - ROW_HEIGHT * 10
if (viewportBottom >= fetchThreshold) {
onFetchMore()
}
}, [lazyLoadingRefs, containerRef])
useEffect(() => {
if (!isLazyMode || !hasData) {
return
}
const container = containerRef.current
if (!container) {
return
}
const timeoutId = setTimeout(() => {
const scrollElement: HTMLElement | null = containerRef.current
if (!scrollElement) {
return
}
scrollElement.addEventListener("scroll", handleScroll)
}, 100)
return () => {
clearTimeout(timeoutId)
const scrollElement = containerRef.current
scrollElement?.removeEventListener("scroll", handleScroll)
}
}, [isLazyMode, hasData])
const columnVirtualizer = useVirtualizer({
count: visibleColumns.length,
estimateSize: (index) => visibleColumns[index].getSize(),
@@ -552,6 +661,7 @@ export const DataGridRoot = <
<DataGridContext.Provider value={values}>
<div className="bg-ui-bg-subtle flex size-full flex-col">
<DataGridHeader
showColumnsDropdown={showColumnsDropdown}
columnOptions={columnOptions}
isDisabled={isColumsDisabled}
onToggleColumn={handleToggleColumnVisibility}
@@ -560,6 +670,7 @@ export const DataGridRoot = <
onResetColumns={handleResetColumns}
isHighlighted={isHighlighted}
onHeaderInteractionChange={handleHeaderInteractionChange}
headerContent={headerContent}
/>
<div className="size-full overflow-hidden">
<div
@@ -650,6 +761,20 @@ export const DataGridRoot = <
>
{virtualRows.map((virtualRow) => {
const row = visibleRows[virtualRow.index] as Row<TData>
// In lazy mode, rows beyond loaded data show as skeleton
if (!row) {
return (
<DataGridRowSkeleton
key={`skeleton-${virtualRow.index}`}
virtualRow={virtualRow}
virtualColumns={virtualColumns}
virtualPaddingLeft={virtualPaddingLeft}
virtualPaddingRight={virtualPaddingRight}
/>
)
}
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
return (
@@ -658,6 +783,7 @@ export const DataGridRoot = <
row={row}
rowIndex={rowIndex}
virtualRow={virtualRow}
rowVirtualizer={rowVirtualizer}
flatColumns={flatColumns}
virtualColumns={virtualColumns}
anchor={anchor}
@@ -680,12 +806,14 @@ export const DataGridRoot = <
type DataGridHeaderProps = {
columnOptions: GridColumnOption[]
isDisabled: boolean
showColumnsDropdown: boolean
onToggleColumn: (index: number) => (value: boolean) => void
onResetColumns: () => void
isHighlighted: boolean
errorCount: number
onToggleErrorHighlighting: () => void
onHeaderInteractionChange: (isActive: boolean) => void
headerContent?: ReactNode
}
const DataGridHeader = ({
@@ -697,6 +825,8 @@ const DataGridHeader = ({
errorCount,
onToggleErrorHighlighting,
onHeaderInteractionChange,
showColumnsDropdown,
headerContent,
}: DataGridHeaderProps) => {
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [columnsOpen, setColumnsOpen] = useState(false)
@@ -717,58 +847,61 @@ const DataGridHeader = ({
}
return (
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
<div className="flex items-center gap-x-2">
<DropdownMenu
dir={direction}
open={columnsOpen}
onOpenChange={handleColumnsOpenChange}
>
<ConditionalTooltip
showTooltip={isDisabled}
content={t("dataGrid.columns.disabled")}
{showColumnsDropdown && (
<div className="flex items-center gap-x-2">
<DropdownMenu
dir={direction}
open={columnsOpen}
onOpenChange={handleColumnsOpenChange}
>
<DropdownMenu.Trigger asChild disabled={isDisabled}>
<Button size="small" variant="secondary">
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
{t("dataGrid.columns.view")}
</Button>
</DropdownMenu.Trigger>
</ConditionalTooltip>
<DropdownMenu.Content>
{columnOptions.map((column, index) => {
const { checked, disabled, id, name } = column
<ConditionalTooltip
showTooltip={isDisabled}
content={t("dataGrid.columns.disabled")}
>
<DropdownMenu.Trigger asChild disabled={isDisabled}>
<Button size="small" variant="secondary">
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
{t("dataGrid.columns.view")}
</Button>
</DropdownMenu.Trigger>
</ConditionalTooltip>
<DropdownMenu.Content>
{columnOptions.map((column, index) => {
const { checked, disabled, id, name } = column
if (disabled) {
return null
}
if (disabled) {
return null
}
return (
<DropdownMenu.CheckboxItem
key={id}
checked={checked}
onCheckedChange={onToggleColumn(index)}
onSelect={(e) => e.preventDefault()}
>
{name}
</DropdownMenu.CheckboxItem>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
{hasChanged && (
<Button
size="small"
variant="transparent"
type="button"
onClick={onResetColumns}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
data-id="reset-columns"
>
{t("dataGrid.columns.resetToDefault")}
</Button>
)}
</div>
<div className="flex items-center gap-x-2">
return (
<DropdownMenu.CheckboxItem
key={id}
checked={checked}
onCheckedChange={onToggleColumn(index)}
onSelect={(e) => e.preventDefault()}
>
{name}
</DropdownMenu.CheckboxItem>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
{hasChanged && (
<Button
size="small"
variant="transparent"
type="button"
onClick={onResetColumns}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
data-id="reset-columns"
>
{t("dataGrid.columns.resetToDefault")}
</Button>
)}
</div>
)}
{headerContent}
<div className="ml-auto flex items-center gap-x-2">
{errorCount > 0 && (
<Button
size="small"
@@ -832,11 +965,11 @@ const DataGridCell = <TData,>({
data-row-index={rowIndex}
data-column-index={columnIndex}
className={clx(
"relative flex items-center border-b border-r p-0 outline-none"
"relative flex items-stretch border-b border-r p-0 outline-none"
)}
tabIndex={-1}
>
<div className="relative h-full w-full">
<div className="relative w-full">
{flexRender(cell.column.columnDef.cell, {
...cell.getContext(),
columnIndex,
@@ -861,10 +994,11 @@ const DataGridCell = <TData,>({
type DataGridRowProps<TData> = {
row: Row<TData>
rowIndex: number
virtualRow: VirtualItem<Element>
virtualRow: VirtualItem
rowVirtualizer: Virtualizer<HTMLDivElement, Element>
virtualPaddingLeft?: number
virtualPaddingRight?: number
virtualColumns: VirtualItem<Element>[]
virtualColumns: VirtualItem[]
flatColumns: Column<TData, unknown>[]
anchor: DataGridCoordinates | null
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
@@ -875,6 +1009,7 @@ const DataGridRow = <TData,>({
row,
rowIndex,
virtualRow,
rowVirtualizer,
virtualPaddingLeft,
virtualPaddingRight,
virtualColumns,
@@ -889,10 +1024,12 @@ const DataGridRow = <TData,>({
<div
role="row"
aria-rowindex={virtualRow.index}
data-index={virtualRow.index}
ref={(node) => rowVirtualizer.measureElement(node)}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
className="bg-ui-bg-subtle txt-compact-small absolute flex min-h-10 w-full"
>
{virtualPaddingLeft ? (
<div
@@ -943,3 +1080,77 @@ const DataGridRow = <TData,>({
</div>
)
}
/**
* Skeleton row component for lazy loading.
* Displays placeholder cells while data is being fetched.
*/
type DataGridRowSkeletonProps = {
virtualRow: VirtualItem
virtualPaddingLeft?: number
virtualPaddingRight?: number
virtualColumns: VirtualItem[]
}
const DataGridRowSkeleton = ({
virtualRow,
virtualPaddingLeft,
virtualPaddingRight,
virtualColumns,
}: DataGridRowSkeletonProps) => {
return (
<div
role="row"
aria-rowindex={virtualRow.index}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
>
{virtualPaddingLeft ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.map((vc, index, array) => {
const previousVC = array[index - 1]
const elements: ReactNode[] = []
if (previousVC && vc.index !== previousVC.index + 1) {
elements.push(
<div
key={`padding-${previousVC.index}-${vc.index}`}
role="presentation"
style={{
display: "flex",
width: `${vc.start - previousVC.end}px`,
}}
/>
)
}
elements.push(
<div
key={`skeleton-cell-${vc.index}`}
role="gridcell"
style={{ width: vc.size }}
className="relative flex items-center border-b border-r p-0 outline-none"
>
<div className="flex h-full w-full items-center px-4">
<div className="bg-ui-bg-component h-4 w-3/4 animate-pulse rounded" />
</div>
</div>
)
return elements
})}
{virtualPaddingRight ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingRight }}
/>
) : null}
</div>
)
}
@@ -1,5 +1,5 @@
import { clx } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
@@ -43,29 +43,29 @@ const Inner = ({
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
const [localValue, setLocalValue] = useState(value)
const inputElRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
const combinedRefs = useCombinedRefs(inputRef, ref)
const combinedRefs = useCombinedRefs(inputRef, ref, inputElRef)
return (
<input
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none",
"focus:cursor-text"
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer bg-transparent outline-none",
"focus:cursor-text",
"items-center justify-center"
)}
autoComplete="off"
tabIndex={-1}
value={localValue}
value={localValue ?? ""}
onChange={(e) => setLocalValue(e.target.value)}
ref={combinedRefs}
onBlur={() => {
onBlur()
onInputBlur()
// We propagate the change to the field only when the input is blurred
onChange(localValue, value)
}}
{...input}
@@ -0,0 +1,233 @@
import { clx, Textarea } from "@medusajs/ui"
import { Popover as RadixPopover } from "radix-ui"
import { useCallback, useEffect, useRef, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { useDataGridContext } from "../context"
import { DataGridCellProps, InputProps, DataGridCellContext } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
type DataGridExpandableTextCellProps<TData, TValue = any> = DataGridCellProps<
TData,
TValue
> & {
fieldLabel?: string
}
export const DataGridExpandableTextCell = <TData, TValue = any>({
context,
fieldLabel,
}: DataGridExpandableTextCellProps<TData, TValue>) => {
const { field, control, renderProps } = useDataGridCell({
context,
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
return (
<Controller
control={control}
name={field}
render={({ field }) => {
return (
<Inner
field={field}
inputProps={input}
fieldLabel={fieldLabel}
container={container}
errorProps={errorProps}
/>
)
}}
/>
)
}
const Inner = ({
field,
inputProps,
fieldLabel: _fieldLabel,
container,
errorProps,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
fieldLabel?: string
container: any
errorProps: any
}) => {
const { onChange: _, onBlur, ref, value, ...rest } = field
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
const { setSingleRange, anchor } = useDataGridContext()
const { row, col } = anchor || { row: 0, col: 0 }
const [localValue, setLocalValue] = useState(value || "")
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const [popoverValue, setPopoverValue] = useState(value || "")
const popoverContentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setLocalValue(value || "")
}, [value])
useEffect(() => {
if (isPopoverOpen) {
setPopoverValue(value || "")
}
}, [isPopoverOpen, value])
// Prevent DataGrid keyboard handlers from intercepting keys when popover is open
useEffect(() => {
if (!isPopoverOpen || !popoverContentRef.current) {
return
}
const handleKeyDownCapture = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
const isTextarea = target.tagName === "TEXTAREA"
const isInPopover =
popoverContentRef.current && popoverContentRef.current.contains(target)
if (isTextarea || isInPopover) {
const dataGridKeys = [
"Enter",
"Delete",
"Backspace",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Tab",
" ",
]
// Stop the keys from reaching DataGrid, so the textarea can handle them
if (dataGridKeys.includes(e.key) && e.key !== "Escape") {
e.stopImmediatePropagation()
}
}
}
window.addEventListener("keydown", handleKeyDownCapture, true)
return () => {
window.removeEventListener("keydown", handleKeyDownCapture, true)
}
}, [isPopoverOpen])
const combinedRefs = useCombinedRefs(inputRef, ref)
const handleOverlayMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.detail === 2) {
e.preventDefault()
e.stopPropagation()
setSingleRange({ row, col })
setIsPopoverOpen(true)
return
}
// For single clicks, use the normal handler which sets anchor and focuses container
container.overlayProps.onMouseDown?.(e)
},
[container.overlayProps, setSingleRange, row, col]
)
const customContainer = {
...container,
overlayProps: {
...container.overlayProps,
onMouseDown: handleOverlayMouseDown,
},
}
const handlePopoverSave = () => {
onChange(popoverValue, value)
setLocalValue(popoverValue)
setIsPopoverOpen(false)
onBlur()
onInputBlur()
}
const handlePopoverKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key !== "Escape") {
e.stopPropagation()
}
}, [])
const displayValue = localValue || ""
const truncatedValue =
displayValue.length > 50
? `${displayValue.substring(0, 50)}...`
: displayValue
return (
<RadixPopover.Root
open={isPopoverOpen}
onOpenChange={(open) => {
if (!open) {
handlePopoverSave()
} else {
setIsPopoverOpen(true)
}
}}
>
<DataGridCellContainer {...customContainer} {...errorProps}>
<RadixPopover.Anchor asChild>
<div
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none",
"focus:cursor-text"
)}
>
<span className="w-full truncate text-center">
{truncatedValue}
</span>
</div>
</RadixPopover.Anchor>
<input
className="sr-only"
autoComplete="off"
tabIndex={-1}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
ref={combinedRefs}
onBlur={() => {
onBlur()
onInputBlur()
onChange(localValue, value)
}}
{...input}
{...rest}
/>
</DataGridCellContainer>
<RadixPopover.Portal>
<RadixPopover.Content
className={clx(
"bg-ui-bg-base shadow-elevation-flyout flex max-h-[80vh] w-[600px] overflow-hidden p-0 outline-none"
)}
align="start"
side="bottom"
sideOffset={-29}
alignOffset={-16}
collisionPadding={24}
onEscapeKeyDown={handlePopoverSave}
onKeyDown={handlePopoverKeyDown}
>
<div ref={popoverContentRef} className="h-full w-full">
<Textarea
value={popoverValue}
onChange={(e) => setPopoverValue(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
}}
className="!bg-ui-bg-base h-full min-h-[300px] w-full resize-none border-0 p-4 !shadow-none focus-visible:!shadow-none"
/>
</div>
</RadixPopover.Content>
</RadixPopover.Portal>
</RadixPopover.Root>
)
}
@@ -1,7 +1,9 @@
export { DataGridBooleanCell } from "./data-grid-boolean-cell"
export { DataGridCurrencyCell } from "./data-grid-currency-cell"
export { DataGridMultilineCell } from "./data-grid-multiline-cell"
export { DataGridNumberCell } from "./data-grid-number-cell"
export { DataGridReadonlyCell as DataGridReadOnlyCell } from "./data-grid-readonly-cell"
export { DataGridRoot, type DataGridRootProps } from "./data-grid-root"
export { DataGridSkeleton } from "./data-grid-skeleton"
export { DataGridTextCell } from "./data-grid-text-cell"
export { DataGridExpandableTextCell } from "./data-grid-textarea-modal-cell"
@@ -3,11 +3,13 @@ import { FieldValues } from "react-hook-form"
import {
DataGridBooleanCell,
DataGridCurrencyCell,
DataGridMultilineCell,
DataGridNumberCell,
DataGridReadOnlyCell,
DataGridRoot,
DataGridSkeleton,
DataGridTextCell,
DataGridExpandableTextCell,
type DataGridRootProps,
} from "./components"
@@ -18,6 +20,11 @@ interface DataGridProps<TData, TFieldValues extends FieldValues = FieldValues>
const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
isLoading,
// Lazy loading props - passed through to DataGridRoot
totalRowCount,
onFetchMore,
isFetchingMore,
hasNextPage,
...props
}: DataGridProps<TData, TFieldValues>) => {
return isLoading ? (
@@ -28,13 +35,21 @@ const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
}
/>
) : (
<DataGridRoot {...props} />
<DataGridRoot
{...props}
totalRowCount={totalRowCount}
onFetchMore={onFetchMore}
isFetchingMore={isFetchingMore}
hasNextPage={hasNextPage}
/>
)
}
export const DataGrid = Object.assign(_DataGrid, {
BooleanCell: DataGridBooleanCell,
TextCell: DataGridTextCell,
MultilineCell: DataGridMultilineCell,
ExpandableTextCell: DataGridExpandableTextCell,
NumberCell: DataGridNumberCell,
CurrencyCell: DataGridCurrencyCell,
ReadonlyCell: DataGridReadOnlyCell,
@@ -36,6 +36,18 @@ type DataGridHelperColumnsProps<TData, TFieldValues extends FieldValues> = {
* @default false
*/
disableHiding?: boolean
/**
* The size of the column in pixels.
*/
size?: number
/**
* The minimum size of the column in pixels.
*/
minSize?: number
/**
* The maximum size of the column in pixels.
*/
maxSize?: number
} & (
| {
field: FieldFunction<TData, TFieldValues>
@@ -59,12 +71,18 @@ export function createDataGridHelper<
disableHiding = false,
field,
type,
size,
minSize,
maxSize,
}: DataGridHelperColumnsProps<TData, TFieldValues>) =>
columnHelper.display({
id,
header,
cell,
enableHiding: !disableHiding,
size,
minSize,
maxSize,
meta: {
name,
field,
@@ -128,6 +128,7 @@ export const useDataGridCell = <TData, TValue>({
case "number":
return numberCharacterRegex.test(key)
case "text":
case "multiline-text":
return textCharacterRegex.test(key)
default:
// KeyboardEvents should not be forwareded to other types of cells
@@ -180,6 +181,17 @@ export const useDataGridCell = <TData, TValue>({
)?.set
nativeInputValueSetter?.call(inputRef.current, e.key)
const event = new Event("input", { bubbles: true })
inputRef.current.dispatchEvent(event)
} else if (inputRef.current instanceof HTMLTextAreaElement) {
inputRef.current.value = ""
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value"
)?.set
nativeTextAreaValueSetter?.call(inputRef.current, e.key)
// Trigger input event to notify react-hook-form
const event = new Event("input", { bubbles: true })
inputRef.current.dispatchEvent(event)
@@ -202,7 +214,7 @@ export const useDataGridCell = <TData, TValue>({
useEffect(() => {
if (isAnchor && !containerRef.current?.contains(document.activeElement)) {
containerRef.current?.focus()
containerRef.current?.focus({ preventScroll: true })
}
}, [isAnchor])
@@ -228,6 +228,7 @@ export function convertArrayToPrimitive(
case "boolean":
return values.map(convertToBoolean)
case "text":
case "multiline-text":
return values.map(covertToString)
default:
throw new Error(`Unsupported target type "${type}".`)
@@ -211,7 +211,22 @@ export const useDataGridKeydownEvent = <
[rangeEnd, matrix, getSelectionValues, setSelectionValues, execute]
)
const handleSpaceKeyTextOrNumber = useCallback(
const handleSpaceKeyText = useCallback(
(anchor: DataGridCoordinates) => {
const field = matrix.getCellField(anchor)
const input = queryTool?.getInput(anchor)
if (!field || !input) {
return
}
createSnapshot(anchor)
input.focus()
},
[matrix, queryTool, createSnapshot]
)
const handleSpaceKeyNumber = useCallback(
(anchor: DataGridCoordinates) => {
const field = matrix.getCellField(anchor)
const input = queryTool?.getInput(anchor)
@@ -303,9 +318,12 @@ export const useDataGridKeydownEvent = <
case "togglable-number":
handleSpaceKeyTogglableNumber(anchor)
break
case "number":
case "text":
handleSpaceKeyTextOrNumber(anchor)
case "multiline-text":
handleSpaceKeyText(anchor)
break
case "number":
handleSpaceKeyNumber(anchor)
break
}
},
@@ -314,7 +332,8 @@ export const useDataGridKeydownEvent = <
isEditing,
matrix,
handleSpaceKeyBoolean,
handleSpaceKeyTextOrNumber,
handleSpaceKeyText,
handleSpaceKeyNumber,
handleSpaceKeyTogglableNumber,
]
)
@@ -384,6 +403,30 @@ export const useDataGridKeydownEvent = <
[handleMoveOnEnter, handleEditOnEnter, isEditing]
)
/**
* Handles the enter key for multiline-text cells.
*
* The behavior is as follows:
* - If Shift+Enter is pressed while editing, allow the newline (don't prevent default).
* - If Enter is pressed while editing (without Shift), move to the next cell.
* - If the cell is currently not being edited, start editing the cell.
*/
const handleEnterKeyMultilineText = useCallback(
(e: KeyboardEvent, anchor: DataGridCoordinates) => {
if (isEditing) {
if (e.shiftKey) {
return
}
handleMoveOnEnter(e, anchor)
return
}
handleEditOnEnter(anchor)
},
[handleMoveOnEnter, handleEditOnEnter, isEditing]
)
/**
* Handles the enter key for boolean cells.
*
@@ -432,11 +475,18 @@ export const useDataGridKeydownEvent = <
return
}
e.preventDefault()
const type = matrix.getCellType(anchor)
if (type === "multiline-text" && isEditing && e.shiftKey) {
return
}
e.preventDefault()
switch (type) {
case "multiline-text":
handleEnterKeyMultilineText(e, anchor)
break
case "togglable-number":
case "text":
case "number":
@@ -448,7 +498,14 @@ export const useDataGridKeydownEvent = <
}
}
},
[anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean]
[
anchor,
matrix,
isEditing,
handleEnterKeyTextOrNumber,
handleEnterKeyBoolean,
handleEnterKeyMultilineText,
]
)
const handleDeleteKeyTogglableNumber = useCallback(
@@ -526,6 +583,7 @@ export const useDataGridKeydownEvent = <
switch (type) {
case "text":
case "multiline-text":
case "number":
handleDeleteKeyTextOrNumber(anchor, rangeEnd)
break
@@ -16,6 +16,7 @@ import {
export type DataGridColumnType =
| "text"
| "multiline-text"
| "number"
| "boolean"
| "togglable-number"
@@ -9,6 +9,7 @@ import { useExtension } from "../../../providers/extension-provider"
import { INavItem, NavItem } from "../nav-item"
import { Shell } from "../shell"
import { UserMenu } from "../user-menu"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
export const SettingsLayout = () => {
return (
@@ -19,6 +20,7 @@ export const SettingsLayout = () => {
}
const useSettingRoutes = (): INavItem[] => {
const isTranslationsEnabled = useFeatureFlag("translation")
const { t } = useTranslation()
return useMemo(
@@ -63,8 +65,16 @@ const useSettingRoutes = (): INavItem[] => {
label: t("stockLocations.domain"),
to: "/settings/locations",
},
...(isTranslationsEnabled
? [
{
label: t("translations.domain"),
to: "/settings/translations",
},
]
: []),
],
[t]
[t, isTranslationsEnabled]
)
}
@@ -8,7 +8,7 @@ import { RouteModalProvider } from "../route-modal-provider/route-provider"
import { StackedModalProvider } from "../stacked-modal-provider"
type RouteFocusModalProps = PropsWithChildren<{
prev?: string | Partial<Path>
prev?: string | Partial<Path> | number
}>
const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
@@ -16,7 +16,8 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
const [open, setOpen] = useState(false)
const [stackedModalOpen, onStackedModalOpen] = useState(false)
const to = useStateAwareTo(prev)
const to: string | Partial<Path> | number =
typeof prev === "number" ? prev : useStateAwareTo(prev)
/**
* Open the modal when the component mounts. This
@@ -34,7 +35,11 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
const handleOpenChange = (open: boolean) => {
if (!open) {
document.body.style.pointerEvents = "auto"
navigate(to, { replace: true })
if (typeof to === "number") {
navigate(to)
} else {
navigate(to, { replace: true })
}
return
}
@@ -24,7 +24,12 @@ export const RouteModalForm = <TFieldValues extends FieldValues = any>({
} = form
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
const { isSubmitSuccessful } = nextLocation.state || {}
// Check both nextLocation and currentLocation state for successful submission
// This handles browser history navigation (-1) where we set state on current location
const { isSubmitSuccessful: nextIsSuccessful } = nextLocation.state || {}
const { isSubmitSuccessful: currentIsSuccessful } =
currentLocation.state || {}
const isSubmitSuccessful = nextIsSuccessful || currentIsSuccessful
if (isSubmitSuccessful) {
onClose?.(true)
@@ -1,9 +1,9 @@
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
import { Path, useNavigate } from "react-router-dom"
import { Path, useLocation, useNavigate } from "react-router-dom"
import { RouteModalProviderContext } from "./route-modal-context"
type RouteModalProviderProps = PropsWithChildren<{
prev: string | Partial<Path>
prev: string | Partial<Path> | number
}>
export const RouteModalProvider = ({
@@ -11,15 +11,27 @@ export const RouteModalProvider = ({
children,
}: RouteModalProviderProps) => {
const navigate = useNavigate()
const location = useLocation()
const [closeOnEscape, setCloseOnEscape] = useState(true)
const handleSuccess = useCallback(
(path?: string) => {
const to = path || prev
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
if (typeof to === "number") {
// Replace current location with success state, then navigate back
navigate(location.pathname + location.search, {
replace: true,
state: { ...location.state, isSubmitSuccessful: true },
})
setTimeout(() => {
navigate(to)
}, 0)
} else {
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
}
},
[navigate, prev]
[navigate, prev, location]
)
const value = useMemo(
@@ -1840,6 +1840,29 @@ export function getRouteMap({
},
],
},
{
path: "translations",
errorElement: <ErrorBoundary />,
handle: {
breadcrumb: () => t("translations.domain"),
},
children: [
{
path: "",
lazy: () =>
import("../../routes/translations/translation-list"),
},
{
path: "edit",
lazy: () =>
import("../../routes/translations/translations-edit"),
},
{
path: "add-locales",
lazy: () => import("../../routes/translations/add-locales"),
},
],
},
...(settingsRoutes?.[0]?.children || []),
],
},
@@ -1,7 +1,9 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import {
InfiniteData,
QueryKey,
UseInfiniteQueryOptions,
UseMutationOptions,
UseQueryOptions,
useMutation,
@@ -11,6 +13,7 @@ import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { productsQueryKeys } from "./products"
import { useInfiniteList } from "../use-infinite-list"
const CATEGORIES_QUERY_KEY = "categories" as const
export const categoriesQueryKeys = queryKeysFactory(CATEGORIES_QUERY_KEY)
@@ -58,6 +61,35 @@ export const useProductCategories = (
return { ...data, ...rest }
}
export const useInfiniteCategories = (
query?: Omit<HttpTypes.AdminProductCategoryListParams, "offset" | "limit"> & {
limit?: number
},
options?: Omit<
UseInfiniteQueryOptions<
HttpTypes.AdminProductCategoryListResponse,
FetchError,
InfiniteData<HttpTypes.AdminProductCategoryListResponse, number>,
HttpTypes.AdminProductCategoryListResponse,
QueryKey,
number
>,
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
>
) => {
return useInfiniteList<
HttpTypes.AdminProductCategoryListResponse,
HttpTypes.AdminProductCategoryListParams,
FetchError,
QueryKey
>({
queryKey: (params) => categoriesQueryKeys.list(params),
queryFn: (params) => sdk.admin.productCategory.list(params),
query,
options,
})
}
export const useCreateProductCategory = (
options?: UseMutationOptions<
HttpTypes.AdminProductCategoryResponse,
@@ -1,7 +1,9 @@
import { FetchError } from "@medusajs/js-sdk"
import { FindParams, HttpTypes, PaginatedResponse } from "@medusajs/types"
import {
InfiniteData,
QueryKey,
UseInfiniteQueryOptions,
UseMutationOptions,
UseQueryOptions,
useMutation,
@@ -11,6 +13,7 @@ import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { productsQueryKeys } from "./products"
import { useInfiniteList } from "../use-infinite-list"
const COLLECTION_QUERY_KEY = "collections" as const
export const collectionsQueryKeys = queryKeysFactory(COLLECTION_QUERY_KEY)
@@ -57,6 +60,34 @@ export const useCollections = (
return { ...data, ...rest }
}
export const useInfiniteCollections = (
query?: Omit<HttpTypes.AdminCollectionListParams, "offset" | "limit"> & {
limit?: number
},
options?: Omit<
UseInfiniteQueryOptions<
HttpTypes.AdminCollectionListResponse,
FetchError,
InfiniteData<HttpTypes.AdminCollectionListResponse, number>,
HttpTypes.AdminCollectionListResponse,
QueryKey,
number
>,
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
>
) => {
return useInfiniteList<
HttpTypes.AdminCollectionListResponse,
HttpTypes.AdminCollectionListParams,
FetchError,
QueryKey
>({
queryKey: (params) => collectionsQueryKeys.list(params),
queryFn: (params) => sdk.admin.productCollection.list(params),
query,
options,
})
}
export const useUpdateCollection = (
id: string,
options?: UseMutationOptions<
@@ -34,6 +34,7 @@ export * from "./store"
export * from "./tags"
export * from "./tax-rates"
export * from "./tax-regions"
export * from "./translations"
export * from "./users"
export * from "./views"
export * from "./workflow-executions"
@@ -1,7 +1,9 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import {
InfiniteData,
QueryKey,
UseInfiniteQueryOptions,
UseMutationOptions,
UseQueryOptions,
useMutation,
@@ -10,6 +12,7 @@ import {
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { useInfiniteList } from "../use-infinite-list"
const PRODUCT_TYPES_QUERY_KEY = "product_types" as const
export const productTypesQueryKeys = queryKeysFactory(PRODUCT_TYPES_QUERY_KEY)
@@ -57,6 +60,35 @@ export const useProductTypes = (
return { ...data, ...rest }
}
export const useInfiniteProductTypes = (
query?: Omit<HttpTypes.AdminProductTypeListParams, "offset" | "limit"> & {
limit?: number
},
options?: Omit<
UseInfiniteQueryOptions<
HttpTypes.AdminProductTypeListResponse,
FetchError,
InfiniteData<HttpTypes.AdminProductTypeListResponse, number>,
HttpTypes.AdminProductTypeListResponse,
QueryKey,
number
>,
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
>
) => {
return useInfiniteList<
HttpTypes.AdminProductTypeListResponse,
HttpTypes.AdminProductTypeListParams,
FetchError,
QueryKey
>({
queryKey: (params) => productTypesQueryKeys.list(params),
queryFn: (params) => sdk.admin.productType.list(params),
query,
options,
})
}
export const useCreateProductType = (
options?: UseMutationOptions<
HttpTypes.AdminProductTypeResponse,
@@ -1,7 +1,15 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
import {
QueryKey,
UseInfiniteQueryOptions,
useQuery,
UseQueryOptions,
} from "@tanstack/react-query"
import { InfiniteData } from "@tanstack/query-core"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { useInfiniteList } from "../use-infinite-list"
const PRODUCT_VARIANT_QUERY_KEY = "product_variant" as const
export const productVariantQueryKeys = queryKeysFactory(
@@ -9,9 +17,14 @@ export const productVariantQueryKeys = queryKeysFactory(
)
export const useVariants = (
query?: Record<string, any>,
query?: HttpTypes.AdminProductVariantParams,
options?: Omit<
UseQueryOptions<any, FetchError, any, QueryKey>,
UseQueryOptions<
HttpTypes.AdminProductVariantListResponse,
FetchError,
HttpTypes.AdminProductVariantListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
@@ -23,3 +36,34 @@ export const useVariants = (
return { ...data, ...rest }
}
export const useInfiniteVariants = (
query?: Omit<HttpTypes.AdminProductVariantParams, "offset" | "limit"> & {
limit?: number
},
options?: Omit<
UseInfiniteQueryOptions<
HttpTypes.AdminProductVariantListResponse,
FetchError,
InfiniteData<HttpTypes.AdminProductVariantListResponse, number>,
HttpTypes.AdminProductVariantListResponse,
QueryKey,
number
>,
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
>
) => {
return useInfiniteList<
HttpTypes.AdminProductVariantListResponse,
HttpTypes.AdminProductVariantParams,
FetchError,
QueryKey
>({
queryKey: (params) => productVariantQueryKeys.list(params),
queryFn: async (params) => {
return await sdk.admin.productVariant.list(params)
},
query,
options,
})
}
@@ -2,15 +2,18 @@ import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import {
QueryKey,
UseInfiniteQueryOptions,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions,
} from "@tanstack/react-query"
import { InfiniteData } from "@tanstack/query-core"
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { inventoryItemsQueryKeys } from "./inventory.tsx"
import { useInfiniteList } from "../use-infinite-list.tsx"
const PRODUCTS_QUERY_KEY = "products" as const
export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY)
@@ -310,6 +313,32 @@ export const useProducts = (
return { ...data, ...rest }
}
export const useInfiniteProducts = (
query?: HttpTypes.AdminProductListParams,
options?: Omit<
UseInfiniteQueryOptions<
HttpTypes.AdminProductListResponse,
FetchError,
InfiniteData<HttpTypes.AdminProductListResponse, number>,
HttpTypes.AdminProductListResponse,
QueryKey,
number
>,
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
>
) => {
return useInfiniteList<
HttpTypes.AdminProductListResponse,
HttpTypes.AdminProductListParams,
FetchError,
QueryKey
>({
queryKey: (params) => productsQueryKeys.list(params),
queryFn: (params) => sdk.admin.product.list(params),
query,
options,
})
}
export const useCreateProduct = (
options?: UseMutationOptions<
HttpTypes.AdminProductResponse,
@@ -1,7 +1,9 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import {
InfiniteData,
QueryKey,
UseInfiniteQueryOptions,
UseMutationOptions,
UseQueryOptions,
useMutation,
@@ -10,6 +12,7 @@ import {
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { useInfiniteList } from "../use-infinite-list"
const TAGS_QUERY_KEY = "tags" as const
export const productTagsQueryKeys = queryKeysFactory(TAGS_QUERY_KEY)
@@ -57,6 +60,35 @@ export const useProductTags = (
return { ...data, ...rest }
}
export const useInfiniteProductTags = (
query?: Omit<HttpTypes.AdminProductTagListParams, "offset" | "limit"> & {
limit?: number
},
options?: Omit<
UseInfiniteQueryOptions<
HttpTypes.AdminProductTagListResponse,
FetchError,
InfiniteData<HttpTypes.AdminProductTagListResponse, number>,
HttpTypes.AdminProductTagListResponse,
QueryKey,
number
>,
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
>
) => {
return useInfiniteList<
HttpTypes.AdminProductTagListResponse,
HttpTypes.AdminProductTagListParams,
FetchError,
QueryKey
>({
queryKey: (params) => productTagsQueryKeys.list(params),
queryFn: (params) => sdk.admin.productTag.list(params),
query,
options,
})
}
export const useCreateProductTag = (
query?: HttpTypes.AdminProductTagParams,
options?: UseMutationOptions<
@@ -0,0 +1,315 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import {
QueryKey,
UseInfiniteQueryOptions,
UseInfiniteQueryResult,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions,
} from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { queryClient } from "../../lib/query-client"
import { productsQueryKeys, useInfiniteProducts } from "./products"
import {
productVariantQueryKeys,
useInfiniteVariants,
} from "./product-variants"
import { categoriesQueryKeys, useInfiniteCategories } from "./categories"
import { collectionsQueryKeys, useInfiniteCollections } from "./collections"
import { productTagsQueryKeys, useInfiniteProductTags } from "./tags"
import { productTypesQueryKeys, useInfiniteProductTypes } from "./product-types"
const TRANSLATIONS_QUERY_KEY = "translations" as const
export const translationsQueryKeys = queryKeysFactory(TRANSLATIONS_QUERY_KEY)
const TRANSLATION_SETTINGS_QUERY_KEY = "translation_settings" as const
export const translationSettingsQueryKeys = queryKeysFactory(
TRANSLATION_SETTINGS_QUERY_KEY
)
const TRANSLATION_STATISTICS_QUERY_KEY = "translation_statistics" as const
export const translationStatisticsQueryKeys = queryKeysFactory(
TRANSLATION_STATISTICS_QUERY_KEY
)
export const useReferenceTranslations = (
reference: string,
translatableFields: string[],
referenceId?: string | string[],
options?: Omit<
UseInfiniteQueryOptions<any, FetchError, any, any, QueryKey, number>,
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
>
) => {
const referenceHookMap = new Map<
string,
() => Omit<UseInfiniteQueryResult<any, FetchError>, "data"> & {
data: {
translations: HttpTypes.AdminTranslation[]
references: (Record<string, any> & { id: string })[]
count: number
}
}
>([
[
"product",
() => {
const fields = translatableFields.concat(["translations.*"]).join(",")
const { data, ...rest } = useInfiniteProducts(
{ fields, id: referenceId ?? [] },
options
)
const products = data?.pages.flatMap((page) => page.products) ?? []
return {
...rest,
data: {
translations:
products?.flatMap((product) => product.translations ?? []) ?? [],
references: products ?? [],
count: data?.pages[0]?.count ?? 0,
},
}
},
],
[
"product_variant",
() => {
const fields = translatableFields.concat(["translations.*"]).join(",")
const { data, ...rest } = useInfiniteVariants(
{ id: referenceId ?? [], fields },
options
)
const variants = data?.pages.flatMap((page) => page.variants) ?? []
return {
...rest,
data: {
translations:
variants?.flatMap((variant) => variant.translations ?? []) ?? [],
references: variants ?? [],
translatableFields,
count: data?.pages[0]?.count ?? 0,
},
}
},
],
[
"product_category",
() => {
const fields = translatableFields.concat(["translations.*"]).join(",")
const { data, ...rest } = useInfiniteCategories(
{ id: referenceId ?? [], fields },
options
)
const categories =
data?.pages.flatMap((page) => page.product_categories) ?? []
return {
...rest,
data: {
translations:
categories?.flatMap((category) => category.translations ?? []) ??
[],
references: categories ?? [],
translatableFields,
count: data?.pages[0]?.count ?? 0,
},
}
},
],
[
"product_collection",
() => {
const fields = translatableFields.concat(["translations.*"]).join(",")
const { data, ...rest } = useInfiniteCollections(
{ id: referenceId ?? [], fields },
options
)
const collections =
data?.pages.flatMap((page) => page.collections) ?? []
return {
...rest,
data: {
translations:
collections?.flatMap(
(collection) => collection.translations ?? []
) ?? [],
references: collections ?? [],
translatableFields,
count: data?.pages[0]?.count ?? 0,
},
}
},
],
[
"product_type",
() => {
const fields = translatableFields.concat(["translations.*"]).join(",")
const { data, ...rest } = useInfiniteProductTypes(
{ id: referenceId ?? [], fields },
options
)
const product_types =
data?.pages.flatMap((page) => page.product_types) ?? []
return {
...rest,
data: {
translations:
product_types?.flatMap((type) => type.translations ?? []) ?? [],
references: product_types ?? [],
count: data?.pages[0]?.count ?? 0,
translatableFields,
},
}
},
],
[
"product_tag",
() => {
const fields = translatableFields.concat(["translations.*"]).join(",")
const { data, ...rest } = useInfiniteProductTags(
{ id: referenceId ?? [], fields },
options
)
const product_tags =
data?.pages.flatMap((page) => page.product_tags) ?? []
return {
...rest,
data: {
translations:
product_tags?.flatMap((tag) => tag.translations ?? []) ?? [],
references: product_tags ?? [],
translatableFields,
count: data?.pages[0]?.count ?? 0,
},
}
},
],
// TODO: product option and option values
])
const referenceHook = referenceHookMap.get(reference)
if (!referenceHook) {
throw new Error(`No hook found for reference type: ${reference}`)
}
const { data, ...rest } = referenceHook()
return { ...data, ...rest }
}
export const useTranslations = (
query?: HttpTypes.AdminTranslationsListParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminTranslationsListResponse,
FetchError,
HttpTypes.AdminTranslationsListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: translationsQueryKeys.list(query),
queryFn: () => sdk.admin.translation.list(query),
...options,
})
return { ...data, ...rest }
}
const referenceInvalidationKeysMap = new Map<string, QueryKey>([
["product", productsQueryKeys.lists()],
["product_variant", productVariantQueryKeys.lists()],
["product_category", categoriesQueryKeys.lists()],
["product_collection", collectionsQueryKeys.lists()],
["product_type", productTypesQueryKeys.lists()],
["product_tag", productTagsQueryKeys.lists()],
])
export const useBatchTranslations = (
reference: string,
options?: UseMutationOptions<
HttpTypes.AdminTranslationsBatchResponse,
FetchError,
HttpTypes.AdminBatchTranslations
>
) => {
const mutation = useMutation({
mutationFn: (payload: HttpTypes.AdminBatchTranslations) =>
sdk.admin.translation.batch(payload),
...options,
})
/**
* Useful to call the invalidation separately from the batch request and await the refetch finishes.
*/
const invalidateQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: referenceInvalidationKeysMap.get(reference),
}),
queryClient.invalidateQueries({
queryKey: translationStatisticsQueryKeys.lists(),
}),
])
}
return {
...mutation,
invalidateQueries,
}
}
export const useTranslationSettings = (
query?: HttpTypes.AdminTranslationSettingsParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminTranslationSettingsResponse,
FetchError,
HttpTypes.AdminTranslationSettingsResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: translationSettingsQueryKeys.list(query),
queryFn: () => sdk.admin.translation.settings(query),
...options,
})
return { ...data, ...rest }
}
export const useTranslationStatistics = (
query?: HttpTypes.AdminTranslationStatisticsParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminTranslationStatisticsResponse,
FetchError,
HttpTypes.AdminTranslationStatisticsResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: translationStatisticsQueryKeys.list(query),
queryFn: () => sdk.admin.translation.statistics(query),
...options,
})
return { ...data, ...rest }
}
@@ -0,0 +1,92 @@
import { FetchError } from "@medusajs/js-sdk"
import { PaginatedResponse } from "@medusajs/types"
import {
QueryKey,
UseInfiniteQueryOptions,
InfiniteData,
useInfiniteQuery,
} from "@tanstack/react-query"
/**
* Generic hook for infinite queries with pagination support.
*
* @template TResponse - The response type that must include count, offset, and limit
* @template TParams - The query parameters type (offset and limit will be handled internally)
* @template TError - The error type (defaults to FetchError)
* @template TQueryKey - The query key type (defaults to QueryKey)
*
* @param config - Configuration object
* @param config.queryKey - Function or array that generates the query key
* @param config.queryFn - Function that fetches data with offset and limit
* @param config.query - Query parameters (offset is ignored, limit is optional)
* @param config.options - Additional options for useInfiniteQuery
*
* @example
* ```ts
* const { data, fetchNextPage, hasNextPage } = useInfiniteList({
* queryKey: (params) => productVariantQueryKeys.list(params),
* queryFn: async (params) => sdk.admin.productVariant.list(params),
* query: { status: "published" },
* })
* ```
*/
export const useInfiniteList = <
TResponse extends PaginatedResponse<unknown>,
TParams extends { offset?: number; limit?: number } = {
offset?: number
limit?: number
},
TError = FetchError,
TQueryKey extends QueryKey = QueryKey
>({
queryKey,
queryFn,
query,
options,
}: {
queryKey: ((params: Omit<TParams, "limit">) => TQueryKey) | TQueryKey
queryFn: (params: TParams) => Promise<TResponse>
query?: TParams
options?: Omit<
UseInfiniteQueryOptions<
TResponse,
TError,
InfiniteData<TResponse, number>,
TResponse,
TQueryKey,
number
>,
"queryKey" | "queryFn" | "initialPageParam" | "getNextPageParam"
>
}) => {
const { limit = 50, offset: _, ..._query } = query ?? {}
const resolvedQueryKey =
typeof queryKey === "function"
? queryKey(_query as Omit<TParams, "limit">)
: queryKey
const infiniteQueryKey =
resolvedQueryKey[resolvedQueryKey.length - 1] === "__infinite"
? resolvedQueryKey
: ([...resolvedQueryKey, "__infinite"] as unknown as TQueryKey)
return useInfiniteQuery<
TResponse,
TError,
InfiniteData<TResponse, number>,
TQueryKey,
number
>({
// Infinite queries must not share the exact same queryKey as non-infinite queries,
// since the cached data shape differs (InfiniteData<T> vs T).
queryKey: infiniteQueryKey,
queryFn: ({ pageParam = 0 }) => {
return queryFn({ ..._query, limit, offset: pageParam } as TParams)
},
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
},
...options,
})
}
@@ -38,6 +38,9 @@
"search": {
"type": "string"
},
"original": {
"type": "string"
},
"of": {
"type": "string"
},
@@ -104,6 +107,9 @@
"removed": {
"type": "string"
},
"remaining": {
"type": "string"
},
"admin": {
"type": "string"
},
@@ -191,6 +197,7 @@
"apply",
"range",
"search",
"original",
"of",
"results",
"pages",
@@ -213,6 +220,7 @@
"modified",
"added",
"removed",
"remaining",
"admin",
"store",
"details",
@@ -381,6 +389,12 @@
"save": {
"type": "string"
},
"saveChanges": {
"type": "string"
},
"saveAndClose": {
"type": "string"
},
"saveAsDraft": {
"type": "string"
},
@@ -513,7 +527,9 @@
},
"required": [
"save",
"saveChanges",
"saveAsDraft",
"saveAndClose",
"copy",
"copied",
"duplicate",
@@ -9328,6 +9344,112 @@
],
"additionalProperties": false
},
"translations": {
"type": "object",
"properties": {
"domain": {
"type": "string"
},
"subtitle": {
"type": "string"
},
"list": {
"type": "object",
"properties": {
"metrics": {
"type": "string"
}
},
"required": ["metrics"]
},
"actions": {
"type": "object",
"properties": {
"manageLocales": {
"type": "string"
},
"manage": {
"type": "string"
}
},
"required": ["manageLocales", "manage"]
},
"edit": {
"type": "object",
"properties": {
"successToast": {
"type": "string"
},
"unsavedChanges": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["title", "description"]
}
},
"required": ["successToast", "unsavedChanges"]
},
"bulk": {
"type": "object",
"properties": {
"header": {
"type": "string"
},
"mainColumn": {
"type": "string"
}
},
"required": ["header"]
},
"activeLocales": {
"type": "object",
"properties": {
"heading": {
"type": "string"
},
"subtitle": {
"type": "string"
},
"noLocalesTip": {
"type": "string"
}
},
"required": ["heading", "subtitle", "noLocalesTip"]
},
"completion": {
"type": "object",
"properties": {
"heading": {
"type": "string"
},
"translated": {
"type": "string"
},
"toTranslate": {
"type": "string"
},
"footer": {
"type": "string"
}
},
"required": ["heading", "translated", "toTranslate", "footer"]
}
},
"required": [
"domain",
"actions",
"subtitle",
"bulk",
"completion",
"edit"
]
},
"store": {
"type": "object",
"properties": {
@@ -11,6 +11,7 @@
"apply": "Apply",
"range": "Range",
"search": "Search",
"original": "Original",
"of": "of",
"results": "results",
"pages": "pages",
@@ -33,6 +34,7 @@
"modified": "Modified",
"added": "Added",
"removed": "Removed",
"remaining": "Left",
"admin": "Admin",
"store": "Store",
"details": "Details",
@@ -99,7 +101,9 @@
},
"actions": {
"save": "Save",
"saveChanges": "Save changes",
"saveAsDraft": "Save as draft",
"saveAndClose": "Save and close",
"copy": "Copy",
"copied": "Copied",
"duplicate": "Duplicate",
@@ -2504,6 +2508,40 @@
"deleteUserSuccess": "User {{name}} deleted successfully",
"invite": "Invite"
},
"translations": {
"domain": "Translations",
"actions": {
"manage": "Manage translations",
"manageLocales": "Manage locales"
},
"title": "Translation domains",
"subtitle": "Manage translations of your data in Medusa",
"list": {
"metrics": "{{translated}} of {{total}} fields translated"
},
"edit": {
"successToast": "Translations updated successfully",
"unsavedChanges": {
"title": "Unsaved translations",
"description": "Don't lose your work. You have changes that haven't been saved yet"
}
},
"bulk": {
"header": "Translations Bulk Editor",
"mainColumn": "Locale"
},
"activeLocales": {
"heading": "Languages",
"subtitle": "Activated translations",
"noLocalesTip": "Configure at least one locale to start translating your data"
},
"completion": {
"heading": "Translated fields",
"translated": "Translated",
"toTranslate": "Missing",
"footer": "Languages"
}
},
"store": {
"domain": "Store",
"manageYourStoresDetails": "Manage your store's details",
@@ -11,6 +11,7 @@
"apply": "Aplicar",
"range": "Rango",
"search": "Buscar",
"original": "Original",
"of": "de",
"results": "resultados",
"pages": "páginas",
@@ -33,6 +34,7 @@
"modified": "Modificado",
"added": "Agregado",
"removed": "Eliminado",
"remaining": "Restantes",
"admin": "Administrador",
"store": "Tienda",
"details": "Detalles",
@@ -120,7 +122,9 @@
},
"actions": {
"save": "Guardar",
"saveChanges": "Guardar cambios",
"saveAsDraft": "Guardar como borrador",
"saveAndClose": "Guardar y cerrar",
"copy": "Copiar",
"copied": "Copiado",
"duplicate": "Duplicar",
@@ -2443,6 +2447,40 @@
"deleteUserSuccess": "Usuario {{name}} eliminado correctamente",
"invite": "Invitar"
},
"translations": {
"domain": "Traducciones",
"list": {
"metrics": "{{translated}} de {{total}} textos traducidos"
},
"actions": {
"manage": "Gestionar traducciones",
"manageLocales": "Gestionar idiomas"
},
"title": "Traducción de dominios",
"subtitle": "Gestiona las traducciones de tus datos en Medusa",
"bulk": {
"header": "Editor de Traducciones en Masa",
"locale": "Idioma"
},
"edit": {
"successToast": "Traducciones actualizadas exitosamente",
"unsavedChanges": {
"title": "Traducciones sin guardar",
"description": "No pierdas tu trabajo. Tienes cambios que no han sido guardados aún"
}
},
"activeLocales": {
"heading": "Idiomas",
"subtitle": "Traducciones activas",
"noLocalesTip": "Configura al menos un idioma para empezar a traducir tu información"
},
"completion": {
"heading": "Textos traducidos",
"translated": "Traducidos",
"toTranslate": "Faltantes",
"footer": "Idiomas"
}
},
"store": {
"domain": "Tienda",
"manageYourStoresDetails": "Gestiona los detalles de tu tienda",
@@ -1,10 +1,11 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading, StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
import { getIsActiveProps, getIsInternalProps } from "../../../common/utils"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type CategoryGeneralSectionProps = {
category: HttpTypes.AdminProductCategory
@@ -14,6 +15,7 @@ export const CategoryGeneralSection = ({
category,
}: CategoryGeneralSectionProps) => {
const { t } = useTranslation()
const isTranslationsEnabled = useFeatureFlag("translation")
const activeProps = getIsActiveProps(category.is_active, t)
const internalProps = getIsInternalProps(category.is_internal, t)
@@ -44,6 +46,19 @@ export const CategoryGeneralSection = ({
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_category&reference_id=${category.id}`,
icon: <GlobeEurope />,
},
],
},
]
: []),
{
actions: [
{
@@ -1,4 +1,4 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { AdminProductCategoryResponse } from "@medusajs/types"
import { Button, Container, Heading, Text } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
@@ -14,6 +14,7 @@ import { useDataTable } from "../../../../../hooks/use-data-table"
import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
import { useCategoryTableColumns } from "./use-category-table-columns"
import { useCategoryTableQuery } from "./use-category-table-query"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
const PAGE_SIZE = 20
@@ -105,6 +106,7 @@ const CategoryRowActions = ({
category: AdminProductCategoryResponse["product_category"]
}) => {
const { t } = useTranslation()
const isTranslationsEnabled = useFeatureFlag("translation")
const handleDelete = useDeleteProductCategoryAction(category)
return (
@@ -119,6 +121,19 @@ const CategoryRowActions = ({
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
icon: <GlobeEurope />,
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_category&reference_id=${category.id}`,
},
],
},
]
: []),
{
actions: [
{
@@ -1,10 +1,11 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading, Text, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteCollection } from "../../../../../hooks/api/collections"
import { useNavigate } from "react-router-dom"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type CollectionGeneralSectionProps = {
collection: HttpTypes.AdminCollection
@@ -16,6 +17,7 @@ export const CollectionGeneralSection = ({
const { t } = useTranslation()
const prompt = usePrompt()
const navigate = useNavigate()
const isTranslationsEnabled = useFeatureFlag("translation")
const { mutateAsync } = useDeleteCollection(collection.id!)
@@ -52,6 +54,19 @@ export const CollectionGeneralSection = ({
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_collection&reference_id=${collection.id}`,
icon: <GlobeEurope />,
},
],
},
]
: []),
{
actions: [
{
@@ -1,10 +1,11 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteCollection } from "../../../../../hooks/api/collections"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
export const CollectionRowActions = ({
collection,
@@ -13,6 +14,7 @@ export const CollectionRowActions = ({
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const isTranslationsEnabled = useFeatureFlag("translation")
const { mutateAsync } = useDeleteCollection(collection.id!)
@@ -47,6 +49,19 @@ export const CollectionRowActions = ({
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
icon: <GlobeEurope />,
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_collection&reference_id=${collection.id}`,
},
],
},
]
: []),
{
actions: [
{
@@ -1,9 +1,10 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type ProductTagGeneralSectionProps = {
productTag: HttpTypes.AdminProductTag
@@ -14,6 +15,7 @@ export const ProductTagGeneralSection = ({
}: ProductTagGeneralSectionProps) => {
const { t } = useTranslation()
const handleDelete = useDeleteProductTagAction({ productTag })
const isTranslationsEnabled = useFeatureFlag("translation")
return (
<Container className="flex items-center justify-between">
@@ -32,6 +34,19 @@ export const ProductTagGeneralSection = ({
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_tag&reference_id=${productTag.id}`,
icon: <GlobeEurope />,
},
],
},
]
: []),
{
actions: [
{
@@ -1,4 +1,4 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Button, Container, Heading } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
@@ -16,6 +16,7 @@ import { useProductTagTableQuery } from "../../../../../hooks/table/query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
import { productTagListLoader } from "../../loader"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
const PAGE_SIZE = 20
@@ -88,6 +89,7 @@ const ProductTagRowActions = ({
}) => {
const { t } = useTranslation()
const handleDelete = useDeleteProductTagAction({ productTag })
const isTranslationsEnabled = useFeatureFlag("translation")
return (
<ActionMenu
@@ -101,6 +103,19 @@ const ProductTagRowActions = ({
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
icon: <GlobeEurope />,
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_tag&reference_id=${productTag.id}`,
},
],
},
]
: []),
{
actions: [
{
@@ -1,9 +1,10 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type ProductTypeGeneralSectionProps = {
productType: HttpTypes.AdminProductType
@@ -17,6 +18,7 @@ export const ProductTypeGeneralSection = ({
productType.id,
productType.value
)
const isTranslationsEnabled = useFeatureFlag("translation")
return (
<Container className="flex items-center justify-between">
@@ -32,6 +34,19 @@ export const ProductTypeGeneralSection = ({
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_type&reference_id=${productType.id}`,
icon: <GlobeEurope />,
},
],
},
]
: []),
{
actions: [
{
@@ -1,8 +1,9 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type ProductTypeRowActionsProps = {
productType: HttpTypes.AdminProductType
@@ -16,6 +17,7 @@ export const ProductTypeRowActions = ({
productType.id,
productType.value
)
const isTranslationsEnabled = useFeatureFlag("translation")
return (
<ActionMenu
@@ -29,6 +31,19 @@ export const ProductTypeRowActions = ({
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
icon: <GlobeEurope />,
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_type&reference_id=${productType.id}`,
},
],
},
]
: []),
{
actions: [
{
@@ -1,4 +1,4 @@
import { Component, PencilSquare, Trash } from "@medusajs/icons"
import { Component, GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
@@ -7,6 +7,7 @@ import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section"
import { useDeleteVariant } from "../../../../../hooks/api/products"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type VariantGeneralSectionProps = {
variant: HttpTypes.AdminProductVariant
@@ -16,6 +17,7 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
const { t } = useTranslation()
const prompt = usePrompt()
const navigate = useNavigate()
const isTranslationsEnabled = useFeatureFlag("translation")
const hasInventoryKit = variant.inventory?.length > 1
@@ -70,6 +72,19 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_variant&reference_id=${variant.id}`,
icon: <GlobeEurope />,
},
],
},
]
: []),
{
actions: [
{
@@ -1,4 +1,4 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading, StatusBadge, toast, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
@@ -8,6 +8,7 @@ import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section"
import { useDeleteProduct } from "../../../../../hooks/api/products"
import { useExtension } from "../../../../../providers/extension-provider"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
const productStatusColor = (status: string) => {
switch (status) {
@@ -35,6 +36,7 @@ export const ProductGeneralSection = ({
const prompt = usePrompt()
const navigate = useNavigate()
const { getDisplays } = useExtension()
const isTranslationsEnabled = useFeatureFlag("translation")
const displays = getDisplays("product", "general")
@@ -85,6 +87,19 @@ export const ProductGeneralSection = ({
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product&reference_id=${product.id}`,
icon: <GlobeEurope />,
},
],
},
]
: []),
{
actions: [
{
@@ -1,4 +1,10 @@
import { Buildings, Component, PencilSquare, Trash } from "@medusajs/icons"
import {
Buildings,
Component,
GlobeEurope,
PencilSquare,
Trash,
} from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import {
Badge,
@@ -26,6 +32,7 @@ import {
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type ProductVariantSectionProps = {
product: HttpTypes.AdminProduct
@@ -38,6 +45,7 @@ export const ProductVariantSection = ({
product,
}: ProductVariantSectionProps) => {
const { t } = useTranslation()
const isTranslationsEnabled = useFeatureFlag("translation")
const { q, order, offset, allow_backorder, manage_inventory } =
useQueryParams(
@@ -70,6 +78,11 @@ export const ProductVariantSection = ({
}
)
const translationParams = new URLSearchParams()
variants?.forEach((variant) => {
translationParams.append("reference_id", variant.id)
})
if (isError) {
throw error
}
@@ -115,6 +128,15 @@ export const ProductVariantSection = ({
to: `stock`,
icon: <Buildings />,
},
...(isTranslationsEnabled
? [
{
icon: <GlobeEurope />,
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product_variant&${translationParams.toString()}`,
},
]
: []),
],
},
],
@@ -225,6 +247,15 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
)
},
},
{
icon: <GlobeEurope />,
label: t("translations.actions.manage"),
onClick: () => {
navigate(
`/settings/translations/edit?reference=product_variant&reference_id=${variant.id}`
)
},
},
]
const secondaryActions: DataTableAction<HttpTypes.AdminProductVariant>[] =
@@ -1,4 +1,4 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { Button, Container, Heading, toast, usePrompt } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { createColumnHelper } from "@tanstack/react-table"
@@ -110,6 +110,7 @@ const ProductActions = ({ product }: { product: HttpTypes.AdminProduct }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useDeleteProduct(product.id)
const isTranslationsEnabled = useFeatureFlag("translation")
const handleDelete = async () => {
const res = await prompt({
@@ -153,6 +154,19 @@ const ProductActions = ({ product }: { product: HttpTypes.AdminProduct }) => {
},
],
},
...(isTranslationsEnabled
? [
{
actions: [
{
icon: <GlobeEurope />,
label: t("translations.actions.manage"),
to: `/settings/translations/edit?reference=product&reference_id=${product.id}`,
},
],
},
]
: []),
{
actions: [
{
@@ -0,0 +1,29 @@
import { RouteFocusModal } from "../../../components/modals/route-focus-modal"
import { useStore } from "../../../hooks/api"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import { useNavigate } from "react-router-dom"
import { AddLocalesForm } from "../../store/store-add-locales/components/add-locales-form/add-locales-form"
export const TranslationsAddLocales = () => {
const isEnabled = useFeatureFlag("translation")
const navigate = useNavigate()
if (!isEnabled) {
navigate(-1)
return null
}
const { store, isPending, isError, error } = useStore()
const ready = !!store && !isPending
if (isError) {
throw error
}
return (
<RouteFocusModal>
{ready && <AddLocalesForm store={store} />}
</RouteFocusModal>
)
}
@@ -0,0 +1 @@
export { TranslationsAddLocales as Component } from "./add-locales"
@@ -0,0 +1,93 @@
import { PencilSquare, Language } from "@medusajs/icons"
import {
Container,
Heading,
IconButton,
InlineTip,
Text,
Tooltip,
} from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { IconAvatar } from "../../../../../components/common/icon-avatar"
import { HttpTypes } from "@medusajs/types"
import { useCallback, useState } from "react"
import { useNavigate } from "react-router-dom"
type ActiveLocalesSectionProps = {
locales: HttpTypes.AdminLocale[]
}
export const ActiveLocalesSection = ({
locales,
}: ActiveLocalesSectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const [isHovered, setIsHovered] = useState(false)
const handleManageLocales = useCallback(() => {
navigate("/settings/translations/add-locales")
}, [navigate])
const renderLocales = useCallback(() => {
const maxLocalesToDetail = 2
if (locales.length <= maxLocalesToDetail) {
return locales.map((locale) => locale.name).join(", ")
}
return `${locales
.slice(0, maxLocalesToDetail)
.map((locale) => locale.name)
.join(", ")} + ${locales.length - maxLocalesToDetail}`
}, [locales])
const hasLocales = locales.length > 0
return (
<Container className="flex flex-col p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("translations.activeLocales.heading")}</Heading>
<IconButton variant="transparent" onClick={handleManageLocales}>
<PencilSquare></PencilSquare>
</IconButton>
</div>
<div className="px-1 pb-1">
{hasLocales ? (
<Tooltip
open={isHovered}
content={
<div className="flex flex-col gap-y-1 p-1">
{locales.map((locale) => (
<Text key={locale.code} size="small" weight="plus">
{locale.name}
</Text>
))}
</div>
}
>
<Container
className="bg-ui-bg-component border-r-1 flex items-center gap-x-4 px-[19px] py-2"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<IconAvatar className="border-ui-border-base border">
<Language />
</IconAvatar>
<div className="flex flex-col">
<Text size="small" weight="plus">
{t("translations.activeLocales.subtitle")}
</Text>
<Text className="text-ui-fg-subtle" size="small">
{renderLocales()}
</Text>
</div>
</Container>
</Tooltip>
) : (
<InlineTip label="Tip">
{t("translations.activeLocales.noLocalesTip")}
</InlineTip>
)}
</div>
</Container>
)
}
@@ -0,0 +1,48 @@
import { Button, Container, Text } from "@medusajs/ui"
import { Link } from "react-router-dom"
import { TranslatableEntity } from "../../translation-list"
import { useTranslation } from "react-i18next"
type TranslationListSectionProps = {
entities: TranslatableEntity[]
hasLocales: boolean
}
export const TranslationListSection = ({
entities,
hasLocales = false,
}: TranslationListSectionProps) => {
const { t } = useTranslation()
return (
<Container className="divide-y p-0">
{entities.map((entity) => (
<div
key={entity.reference}
className="grid grid-cols-[250px_1fr_auto] items-center gap-x-4 px-6 py-4"
>
<Text size="small" weight="plus">
{entity.label}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("translations.list.metrics", {
translated: (entity.translatedCount ?? 0).toLocaleString(),
total: (entity.totalCount ?? 0).toLocaleString(),
})}
</Text>
<Link
to={`/settings/translations/edit?reference=${entity.reference}`}
>
<Button
variant="secondary"
size="small"
disabled={!hasLocales || !entity.totalCount}
>
Edit
</Button>
</Link>
</div>
))}
</Container>
)
}
@@ -0,0 +1,285 @@
import { AdminTranslationEntityStatistics, HttpTypes } from "@medusajs/types"
import { Container, Divider, Heading, Text, Tooltip } from "@medusajs/ui"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
type TranslationsCompletionSectionProps = {
statistics: Record<string, AdminTranslationEntityStatistics>
locales: HttpTypes.AdminLocale[]
}
type LocaleStats = {
code: string
name: string
translated: number
toTranslate: number
total: number
}
export const TranslationsCompletionSection = ({
statistics,
locales,
}: TranslationsCompletionSectionProps) => {
const { t } = useTranslation()
const [hoveredLocale, setHoveredLocale] = useState<string | null>(null)
const { translatedCount, totalCount } = Object.values(statistics).reduce(
(acc, curr) => ({
translatedCount: acc.translatedCount + curr.translated,
totalCount: acc.totalCount + curr.expected,
}),
{ totalCount: 0, translatedCount: 0 }
)
const percentage = totalCount > 0 ? (translatedCount / totalCount) * 100 : 0
const remaining = Math.max(0, totalCount - translatedCount)
const localeStats = useMemo((): LocaleStats[] => {
const localeMap = new Map<
string,
{ translated: number; expected: number }
>()
locales.forEach((locale) => {
localeMap.set(locale.code, { translated: 0, expected: 0 })
})
Object.values(statistics).forEach((entityStats) => {
if (entityStats.by_locale) {
Object.entries(entityStats.by_locale).forEach(
([localeCode, localeData]) => {
const existing = localeMap.get(localeCode)
if (existing) {
existing.translated += localeData.translated
existing.expected += localeData.expected
}
}
)
}
})
return locales.map((locale) => {
const stats = localeMap.get(locale.code) || { translated: 0, expected: 0 }
return {
code: locale.code,
name: locale.name,
translated: stats.translated,
toTranslate: Math.max(0, stats.expected - stats.translated),
total: stats.expected,
}
})
}, [statistics, locales])
const maxTotal = useMemo(
() => Math.max(...localeStats.map((s) => s.total), 1),
[localeStats]
)
const localeStatsCount = useMemo(() => localeStats.length, [localeStats])
return (
<Container className="p-0">
<div className="flex flex-col gap-y-4 px-6 py-4">
<div className="flex items-center justify-between">
<Heading level="h2">{t("translations.completion.heading")}</Heading>
<Text size="small" weight="plus" className="text-ui-fg-subtle">
{translatedCount.toLocaleString()} {t("general.of")}{" "}
{totalCount.toLocaleString()}
</Text>
</div>
<div className="flex h-3 w-full overflow-hidden">
{percentage > 0 ? (
<>
<div
className="mr-0.5 h-full rounded-sm transition-all"
style={{
width: `${percentage}%`,
backgroundColor: "var(--tag-blue-icon)",
boxShadow: "inset 0 0 0 0.5px var(--alpha-250)",
}}
/>
<div
className="h-full flex-1 rounded-sm"
style={{
backgroundColor: "var(--tag-blue-border)",
boxShadow: "inset 0 0 0 0.5px var(--alpha-250)",
}}
/>
</>
) : (
<div
className="h-full w-full rounded-sm"
style={{
backgroundColor: "var(--tag-blue-border)",
boxShadow: "inset 0 0 0 0.5px var(--alpha-250)",
}}
/>
)}
</div>
<div className="flex items-center justify-between">
<Text size="small" weight="plus" className="text-ui-fg-subtle">
{percentage.toFixed(1)}%
</Text>
<Text size="small" weight="plus" className="text-ui-fg-subtle">
{remaining.toLocaleString()} {t("general.remaining").toLowerCase()}
</Text>
</div>
</div>
{localeStats.length > 0 && (
<>
<Divider variant="dashed" />
<div className="flex flex-col gap-y-3 px-6 pb-6 pt-4">
<div className="flex h-32 w-full items-end gap-1">
{localeStats.map((locale) => {
const heightPercent = (locale.total / maxTotal) * 100
const translatedPercent =
locale.total > 0
? (locale.translated / locale.total) * 100
: 0
return (
<Tooltip
key={locale.code}
open={hoveredLocale === locale.code}
content={
<div className="flex min-w-[150px] flex-col gap-y-1 p-1">
<Text size="small" weight="plus">
{locale.name}
</Text>
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: "var(--tag-blue-icon)",
boxShadow: "inset 0 0 0 0.5px var(--alpha-250)",
}}
/>
<Text
size="small"
weight="plus"
className="text-ui-fg-base"
>
{t("translations.completion.translated")}
</Text>
</div>
<Text
size="small"
weight="plus"
className="text-ui-fg-base"
>
{locale.translated}
</Text>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: "var(--tag-blue-border)",
boxShadow: "inset 0 0 0 0.5px var(--alpha-250)",
}}
/>
<Text
size="small"
weight="plus"
className="text-ui-fg-base"
>
{t("translations.completion.toTranslate")}
</Text>
</div>
<Text
size="small"
weight="plus"
className="text-ui-fg-base"
>
{locale.toTranslate}
</Text>
</div>
</div>
}
>
<div className="flex h-full flex-1 items-end justify-center">
<div
className="flex w-full min-w-2 max-w-[96px] flex-col justify-end overflow-hidden rounded-t-sm transition-opacity"
style={{ height: `${heightPercent}%` }}
onMouseEnter={() => setHoveredLocale(locale.code)}
onMouseLeave={() => setHoveredLocale(null)}
>
{translatedPercent === 0 ? (
<div
className="w-full rounded-sm"
style={{
height: "100%",
backgroundColor: "var(--tag-neutral-bg)",
boxShadow: "inset 0 0 0 0.5px var(--alpha-250)",
}}
/>
) : (
<>
<div
className="w-full rounded-sm"
style={{
height: `${100 - translatedPercent}%`,
backgroundColor: "var(--tag-blue-border)",
boxShadow: "inset 0 0 0 0.5px var(--alpha-250)",
minHeight: locale.toTranslate > 0 ? "2px" : "0",
}}
/>
{translatedPercent > 0 && (
<div
className="mt-0.5 w-full rounded-sm"
style={{
height: `${translatedPercent}%`,
backgroundColor: "var(--tag-blue-icon)",
boxShadow:
"inset 0 0 0 0.5px var(--alpha-250)",
minHeight:
locale.translated > 0 ? "2px" : "0",
}}
/>
)}
</>
)}
</div>
</div>
</Tooltip>
)
})}
</div>
{localeStatsCount < 9 && (
<div className="flex w-full gap-1">
{localeStats.map((locale) => (
<div
key={locale.code}
className="flex flex-1 items-center justify-center"
>
<Text
size="xsmall"
weight="plus"
className="text-ui-fg-subtle min-w-2 whitespace-normal break-words text-center leading-tight"
>
{localeStatsCount < 6 ? locale.name : locale.code}
</Text>
</div>
))}
</div>
)}
{localeStatsCount > 9 && (
<Text
weight="plus"
size="xsmall"
className="text-ui-fg-subtle text-center"
>
{t("translations.completion.footer")}
</Text>
)}
</div>
</>
)}
</Container>
)
}
@@ -0,0 +1 @@
export { TranslationList as Component } from "./translation-list"
@@ -0,0 +1,138 @@
import { Container, Heading, Text } from "@medusajs/ui"
import { TwoColumnPage } from "../../../components/layout/pages"
import { useTranslation } from "react-i18next"
import {
useStore,
useTranslationSettings,
useTranslationStatistics,
} from "../../../hooks/api"
import { ActiveLocalesSection } from "./components/active-locales-section/active-locales-section"
import { TranslationListSection } from "./components/translation-list-section/translation-list-section"
import { TranslationsCompletionSection } from "./components/translations-completion-section/translations-completion-section"
import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
import { useMemo } from "react"
export type TranslatableEntity = {
label: string
reference: string
translatableFields: string[]
translatedCount?: number
totalCount?: number
}
export const TranslationList = () => {
const { t } = useTranslation()
const { store, isPending, isError, error } = useStore()
const {
translatable_fields,
isPending: isTranslationSettingsPending,
isError: isTranslationSettingsError,
error: translationSettingsError,
} = useTranslationSettings()
const {
statistics,
isPending: isTranslationStatisticsPending,
isError: isTranslationStatisticsError,
error: translationStatisticsError,
} = useTranslationStatistics(
{
locales:
store?.supported_locales?.map(
(suportedLocale) => suportedLocale.locale_code
) ?? [],
entity_types: Object.keys(translatable_fields ?? {}),
},
{
enabled:
!!translatable_fields && !!store && store.supported_locales?.length > 0,
}
)
if (isError || isTranslationSettingsError || isTranslationStatisticsError) {
throw error || translationSettingsError || translationStatisticsError
}
const hasLocales = (store?.supported_locales ?? []).length > 0
const translatableEntities: TranslatableEntity[] = useMemo(() => {
if (!translatable_fields) {
return []
}
return Object.entries(translatable_fields)
.filter(
([entity]) =>
!["product_option", "product_option_value"].includes(entity)
)
.map(([entity, fields]) => {
const entityStatistics = statistics?.[entity] ?? {
translated: 0,
expected: 0,
}
return {
label: entity
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" "),
reference: entity,
translatableFields: fields,
translatedCount: entityStatistics.translated,
totalCount: entityStatistics.expected,
}
})
}, [translatable_fields, statistics])
const isReady =
!!store &&
!isPending &&
!isTranslationSettingsPending &&
!!translatable_fields &&
((!!statistics && !isTranslationStatisticsPending) || !hasLocales)
if (!isReady) {
return <TwoColumnPageSkeleton sidebarSections={2} />
}
return (
<TwoColumnPage
widgets={{
before: [],
after: [],
sideBefore: [],
sideAfter: [],
}}
>
<TwoColumnPage.Main>
<Container className="flex flex-col px-6 py-4">
<Heading>Manage {t("translations.domain")}</Heading>
<Text className="text-ui-fg-subtle" size="small">
{t("translations.subtitle")}
</Text>
</Container>
<TranslationListSection
entities={translatableEntities}
hasLocales={hasLocales}
/>
</TwoColumnPage.Main>
<TwoColumnPage.Sidebar>
<ActiveLocalesSection
locales={
store?.supported_locales?.map(
(suportedLocale) => suportedLocale.locale
) ?? []
}
></ActiveLocalesSection>
<TranslationsCompletionSection
statistics={statistics ?? {}}
locales={
store?.supported_locales?.map(
(supportedLocale) => supportedLocale.locale
) ?? []
}
/>
</TwoColumnPage.Sidebar>
</TwoColumnPage>
)
}
@@ -0,0 +1 @@
export * from "./translations-edit-form"
@@ -0,0 +1,747 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { AdminStoreLocale, HttpTypes } from "@medusajs/types"
import { Button, Prompt, Select, toast, Text } from "@medusajs/ui"
import { ColumnDef } from "@tanstack/react-table"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import {
createDataGridHelper,
DataGrid,
} from "../../../../../components/data-grid"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useBatchTranslations } from "../../../../../hooks/api/translations"
const EntityTranslationsSchema = z.object({
id: z.string().nullish(),
fields: z.record(z.string().optional()),
})
export type EntityTranslationsSchema = z.infer<typeof EntityTranslationsSchema>
export const TranslationsFormSchema = z.object({
entities: z.record(EntityTranslationsSchema),
})
export type TranslationsFormSchema = z.infer<typeof TranslationsFormSchema>
export type TranslationRow = EntityRow | FieldRow
export type EntityRow = {
_type: "entity"
reference_id: string
subRows: FieldRow[]
}
export type FieldRow = {
_type: "field"
reference_id: string
field_name: string
}
export function isEntityRow(row: TranslationRow): row is EntityRow {
return row._type === "entity"
}
export function isFieldRow(row: TranslationRow): row is FieldRow {
return row._type === "field"
}
type LocaleSnapshot = {
localeCode: string
entities: Record<string, EntityTranslationsSchema>
}
function buildLocaleSnapshot(
translations: HttpTypes.AdminTranslation[],
references: { id: string; [key: string]: string }[],
localeCode: string,
translatableFields: string[]
): LocaleSnapshot {
const referenceTranslations = new Map<string, HttpTypes.AdminTranslation>()
for (const t of translations) {
if (t.locale_code === localeCode) {
referenceTranslations.set(t.reference_id, t)
}
}
const entities: Record<string, EntityTranslationsSchema> = {}
for (const ref of references) {
const existing = referenceTranslations.get(ref.id)
const fields: Record<string, string> = {}
for (const fieldName of translatableFields) {
fields[fieldName] = (existing?.translations?.[fieldName] as string) ?? ""
}
entities[ref.id] = {
id: existing?.id ?? null,
fields,
}
}
return { localeCode, entities }
}
function extendSnapshot(
snapshot: LocaleSnapshot,
translations: HttpTypes.AdminTranslation[],
newReferences: { id: string; [key: string]: string }[],
translatableFields: string[]
): LocaleSnapshot {
const referenceTranslations = new Map<string, HttpTypes.AdminTranslation>()
for (const t of translations) {
if (t.locale_code === snapshot.localeCode) {
referenceTranslations.set(t.reference_id, t)
}
}
const extendedEntities = { ...snapshot.entities }
for (const ref of newReferences) {
if (!extendedEntities[ref.id]) {
const existing = referenceTranslations.get(ref.id)
const fields: Record<string, string> = {}
for (const fieldName of translatableFields) {
fields[fieldName] =
(existing?.translations?.[fieldName] as string) ?? ""
}
extendedEntities[ref.id] = {
id: existing?.id ?? null,
fields,
}
}
}
return { ...snapshot, entities: extendedEntities }
}
function snapshotToFormValues(
snapshot: LocaleSnapshot
): TranslationsFormSchema {
return { entities: snapshot.entities }
}
type ChangeDetectionResult = {
hasChanges: boolean
payload: Required<HttpTypes.AdminBatchTranslations>
}
function computeChanges(
currentState: TranslationsFormSchema,
snapshot: LocaleSnapshot,
entityType: string,
localeCode: string
): ChangeDetectionResult {
const payload: Required<HttpTypes.AdminBatchTranslations> = {
create: [],
update: [],
delete: [],
}
for (const [entityId, entityData] of Object.entries(currentState.entities)) {
const baseline = snapshot.entities[entityId]
if (!baseline) {
continue
}
const hasContent = Object.values(entityData.fields).some(
(v) => v !== undefined && v.trim() !== ""
)
const hadContent = Object.values(baseline.fields).some(
(v) => v !== undefined && v.trim() !== ""
)
const hasChanged =
JSON.stringify(entityData.fields) !== JSON.stringify(baseline.fields)
if (!entityData.id && hasContent) {
payload.create.push({
reference_id: entityId,
reference: entityType,
locale_code: localeCode,
translations: entityData.fields,
})
} else if (entityData.id && hasContent && hasChanged) {
payload.update.push({
id: entityData.id,
translations: entityData.fields,
})
} else if (entityData.id && !hasContent && hadContent) {
payload.delete.push(entityData.id)
}
}
const hasChanges =
payload.create.length > 0 ||
payload.update.length > 0 ||
payload.delete.length > 0
return { hasChanges, payload }
}
const columnHelper = createDataGridHelper<
TranslationRow,
TranslationsFormSchema
>()
const FIELD_COLUMN_WIDTH = 350
function buildTranslationRows(
references: { id: string; [key: string]: string }[],
translatableFields: string[]
): TranslationRow[] {
return references.map((reference) => ({
_type: "entity" as const,
reference_id: reference.id,
subRows: translatableFields.map((fieldName) => ({
_type: "field" as const,
reference_id: reference.id,
field_name: fieldName,
})),
}))
}
function useTranslationsGridColumns({
entities,
availableLocales,
selectedLocale,
dynamicColumnWidth,
}: {
entities: { id: string; [key: string]: string }[]
availableLocales: AdminStoreLocale[]
selectedLocale: string
dynamicColumnWidth: number
}) {
const { t } = useTranslation()
return useMemo(() => {
const selectedLocaleData = availableLocales.find(
(l) => l.locale_code === selectedLocale
)
const columns: ColumnDef<TranslationRow>[] = [
columnHelper.column({
id: "field",
name: "field",
size: FIELD_COLUMN_WIDTH,
header: undefined,
cell: (context) => {
const row = context.row.original
if (isEntityRow(row)) {
return <DataGrid.ReadonlyCell context={context} />
}
return (
<DataGrid.ReadonlyCell context={context} color="normal">
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<Text
className="text-ui-fg-subtle truncate"
weight="plus"
size="small"
>
{t(`fields.${row.field_name}`, {
defaultValue: row.field_name,
})}
</Text>
</div>
</DataGrid.ReadonlyCell>
)
},
disableHiding: true,
}),
columnHelper.column({
id: "original",
name: "original",
size: dynamicColumnWidth,
header: () => (
<Text className="text-ui-fg-base" weight="plus" size="small">
{t("general.original")}
</Text>
),
disableHiding: true,
cell: (context) => {
const row = context.row.original
if (isEntityRow(row)) {
return <DataGrid.ReadonlyCell context={context} />
}
const entity = entities.find((e) => e.id === row.reference_id)
if (!entity) {
return null
}
return (
<DataGrid.ReadonlyCell color="normal" context={context} isMultiLine>
<Text className="text-ui-fg-subtle" weight="plus" size="small">
{entity[row.field_name]}
</Text>
</DataGrid.ReadonlyCell>
)
},
}),
]
if (selectedLocaleData) {
columns.push(
columnHelper.column({
id: selectedLocaleData.locale_code,
name: selectedLocaleData.locale.name,
size: dynamicColumnWidth,
header: () => (
<Text className="text-ui-fg-base" weight="plus" size="small">
{selectedLocaleData.locale.name}
</Text>
),
cell: (context) => {
const row = context.row.original
if (isEntityRow(row)) {
return <DataGrid.ReadonlyCell context={context} isMultiLine />
}
return <DataGrid.MultilineCell context={context} />
},
field: (context) => {
const row = context.row.original
if (isEntityRow(row)) {
return null
}
return `entities.${row.reference_id}.fields.${row.field_name}`
},
type: "multiline-text",
})
)
}
return columns
}, [t, availableLocales, selectedLocale, entities, dynamicColumnWidth])
}
type TranslationsEditFormProps = {
translations: HttpTypes.AdminTranslation[]
references: { id: string; [key: string]: string }[]
entityType: string
availableLocales: AdminStoreLocale[]
translatableFields: string[]
fetchNextPage: () => void
hasNextPage: boolean
isFetchingNextPage: boolean
referenceCount: number
}
export const TranslationsEditForm = ({
translations,
references,
entityType,
availableLocales,
translatableFields,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
referenceCount,
}: TranslationsEditFormProps) => {
const { t } = useTranslation()
const { handleSuccess, setCloseOnEscape } = useRouteModal()
const containerRef = useRef<HTMLDivElement>(null)
const [dynamicColumnWidth, setDynamicColumnWidth] = useState(400)
useEffect(() => {
const calculateColumnWidth = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth
const availableWidth = containerWidth - FIELD_COLUMN_WIDTH - 16
const columnWidth = Math.max(300, Math.floor(availableWidth / 2))
setDynamicColumnWidth(columnWidth)
}
}
calculateColumnWidth()
const resizeObserver = new ResizeObserver(calculateColumnWidth)
if (containerRef.current) {
resizeObserver.observe(containerRef.current)
}
return () => resizeObserver.disconnect()
}, [])
const [selectedLocale, setSelectedLocale] = useState<string>(
availableLocales[0]?.locale_code ?? ""
)
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false)
const [pendingLocale, setPendingLocale] = useState<string | null>(null)
const [isSwitchingLocale, setIsSwitchingLocale] = useState(false)
const snapshotRef = useRef<LocaleSnapshot>(
buildLocaleSnapshot(
translations,
references,
selectedLocale,
translatableFields
)
)
const knownEntityIdsRef = useRef<Set<string>>(
new Set(references.map((r) => r.id))
)
const latestPropsRef = useRef({ translations, references })
useEffect(() => {
latestPropsRef.current = { translations, references }
}, [translations, references])
const form = useForm<TranslationsFormSchema>({
resolver: zodResolver(TranslationsFormSchema),
defaultValues: snapshotToFormValues(snapshotRef.current),
})
useEffect(() => {
const currentIds = new Set(references.map((r) => r.id))
const newReferences = references.filter(
(r) => !knownEntityIdsRef.current.has(r.id)
)
if (newReferences.length === 0) {
return
}
knownEntityIdsRef.current = currentIds
snapshotRef.current = extendSnapshot(
snapshotRef.current,
translations,
newReferences,
translatableFields
)
const currentValues = form.getValues()
const newFormValues: TranslationsFormSchema = {
entities: { ...currentValues.entities },
}
for (const ref of newReferences) {
if (!newFormValues.entities[ref.id]) {
newFormValues.entities[ref.id] = snapshotRef.current.entities[ref.id]
}
}
form.reset(newFormValues, {
keepDirty: true,
keepDirtyValues: true,
})
}, [references, translations, translatableFields, form])
const rows = useMemo(
() => buildTranslationRows(references, translatableFields),
[references, translatableFields]
)
const totalRowCount = useMemo(
() => referenceCount * (translatableFields.length + 1),
[referenceCount, translatableFields]
)
const selectedLocaleDisplay = useMemo(
() =>
availableLocales.find((l) => l.locale_code === selectedLocale)?.locale
.name,
[availableLocales, selectedLocale]
)
const columns = useTranslationsGridColumns({
entities: references,
availableLocales,
selectedLocale,
dynamicColumnWidth,
})
const { mutateAsync, isPending, invalidateQueries } =
useBatchTranslations(entityType)
const saveCurrentLocale = useCallback(async () => {
const currentValues = form.getValues()
const { hasChanges, payload } = computeChanges(
currentValues,
snapshotRef.current,
entityType,
selectedLocale
)
if (!hasChanges) {
return true
}
try {
const BATCH_SIZE = 150
const totalItems =
payload.create.length + payload.update.length + payload.delete.length
const batchCount = Math.ceil(totalItems / BATCH_SIZE)
for (let i = 0; i < batchCount; i++) {
let currentBatchAvailable = BATCH_SIZE
const currentBatch: HttpTypes.AdminBatchTranslations = {
create: [],
update: [],
delete: [],
}
if (payload.create.length > 0) {
currentBatch.create = payload.create.splice(0, currentBatchAvailable)
currentBatchAvailable -= currentBatch.create.length
}
if (payload.update.length > 0) {
currentBatch.update = payload.update.splice(0, currentBatchAvailable)
currentBatchAvailable -= currentBatch.update.length
}
if (payload.delete.length > 0) {
currentBatch.delete = payload.delete.splice(0, currentBatchAvailable)
}
const response = await mutateAsync(currentBatch, {
onError: (error) => {
toast.error(error.message)
},
})
if (response.created) {
for (const created of response.created) {
form.setValue(`entities.${created.reference_id}.id`, created.id, {
shouldDirty: false,
})
if (snapshotRef.current.entities[created.reference_id]) {
snapshotRef.current.entities[created.reference_id].id = created.id
}
}
}
}
const savedValues = form.getValues()
for (const entityId of Object.keys(savedValues.entities)) {
if (snapshotRef.current.entities[entityId]) {
snapshotRef.current.entities[entityId] = {
...savedValues.entities[entityId],
}
}
}
form.reset(savedValues)
return true
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to save translations"
)
return false
}
}, [form, entityType, selectedLocale, mutateAsync])
const switchToLocale = useCallback(
async (newLocale: string) => {
setIsSwitchingLocale(true)
try {
await invalidateQueries()
await new Promise((resolve) => requestAnimationFrame(resolve))
const { translations, references } = latestPropsRef.current
const newSnapshot = buildLocaleSnapshot(
translations,
references,
newLocale,
translatableFields
)
snapshotRef.current = newSnapshot
knownEntityIdsRef.current = new Set(references.map((r) => r.id))
form.reset(snapshotToFormValues(newSnapshot))
setSelectedLocale(newLocale)
} finally {
setIsSwitchingLocale(false)
}
},
[translatableFields, form, invalidateQueries]
)
const handleLocaleChange = useCallback(
(newLocale: string) => {
if (newLocale === selectedLocale) {
return
}
const currentValues = form.getValues()
const { hasChanges } = computeChanges(
currentValues,
snapshotRef.current,
entityType,
selectedLocale
)
if (hasChanges) {
setPendingLocale(newLocale)
setShowUnsavedPrompt(true)
} else {
switchToLocale(newLocale)
}
},
[selectedLocale, form, entityType, switchToLocale]
)
const handleSaveAndSwitch = useCallback(async () => {
const success = await saveCurrentLocale()
if (success && pendingLocale) {
toast.success(t("translations.edit.successToast"))
await switchToLocale(pendingLocale)
}
setShowUnsavedPrompt(false)
setPendingLocale(null)
}, [saveCurrentLocale, pendingLocale, t, switchToLocale])
const handleCancelSwitch = useCallback(() => {
setShowUnsavedPrompt(false)
setPendingLocale(null)
}, [])
const handleSave = useCallback(
async (closeOnSuccess: boolean = false) => {
const success = await saveCurrentLocale()
if (success) {
toast.success(t("translations.edit.successToast"))
if (closeOnSuccess) {
handleSuccess()
}
}
},
[saveCurrentLocale, t, handleSuccess]
)
const handleClose = useCallback(() => {
invalidateQueries()
}, [invalidateQueries])
const isLoading = isPending || isSwitchingLocale
return (
<RouteFocusModal.Form form={form} onClose={handleClose}>
<KeyboundForm
onSubmit={() => handleSave(true)}
className="flex h-full flex-col overflow-hidden"
>
<RouteFocusModal.Header />
<RouteFocusModal.Body className="size-full overflow-hidden">
<div ref={containerRef} className="size-full">
<DataGrid
showColumnsDropdown={false}
columns={columns}
data={rows}
getSubRows={(row) => {
if (isEntityRow(row)) {
return row.subRows
}
}}
state={form}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
totalRowCount={totalRowCount}
onFetchMore={fetchNextPage}
isFetchingMore={isFetchingNextPage}
hasNextPage={hasNextPage}
headerContent={
<Select
disabled={isLoading}
value={selectedLocale}
onValueChange={handleLocaleChange}
size="small"
>
<Select.Trigger className="bg-ui-bg-base w-[200px]">
<Select.Value>{selectedLocaleDisplay}</Select.Value>
</Select.Trigger>
<Select.Content>
{availableLocales.map((locale) => (
<Select.Item
key={locale.locale_code}
value={locale.locale_code}
>
{locale.locale.name}
</Select.Item>
))}
</Select.Content>
</Select>
}
/>
</div>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button
type="button"
size="small"
variant="secondary"
isLoading={isLoading}
>
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
size="small"
type="button"
variant="secondary"
onClick={() => handleSave(false)}
isLoading={isLoading}
>
{t("actions.saveChanges")}
</Button>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.saveAndClose")}
</Button>
</div>
</RouteFocusModal.Footer>
</KeyboundForm>
<Prompt open={showUnsavedPrompt} variant="confirmation">
<Prompt.Content>
<Prompt.Header>
<Prompt.Title>
{t("translations.edit.unsavedChanges.title")}
</Prompt.Title>
<Prompt.Description>
{t("translations.edit.unsavedChanges.description")}
</Prompt.Description>
</Prompt.Header>
<Prompt.Footer>
<Button
size="small"
variant="secondary"
onClick={handleCancelSwitch}
type="button"
>
{t("actions.close")}
</Button>
<Button
size="small"
onClick={handleSaveAndSwitch}
type="button"
isLoading={isLoading}
>
{t("actions.saveChanges")}
</Button>
</Prompt.Footer>
</Prompt.Content>
</Prompt>
</RouteFocusModal.Form>
)
}
@@ -0,0 +1 @@
export { TranslationsEdit as Component } from "./translations-edit"
@@ -0,0 +1,90 @@
import { useNavigate, useSearchParams } from "react-router-dom"
import {
useReferenceTranslations,
useStore,
useTranslationSettings,
} from "../../../hooks/api"
import { TranslationsEditForm } from "./components/translations-edit-form"
import { useEffect } from "react"
import { RouteFocusModal } from "../../../components/modals"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import { keepPreviousData } from "@tanstack/react-query"
export const TranslationsEdit = () => {
const isTranslationsEnabled = useFeatureFlag("translation")
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const reference = searchParams.get("reference")
const referenceIdParam = searchParams.getAll("reference_id")
useEffect(() => {
if (!reference || !isTranslationsEnabled) {
navigate(-1)
return
}
}, [reference, navigate, isTranslationsEnabled])
const {
translatable_fields,
isPending: isTranslationSettingsPending,
isError: isTranslationSettingsError,
error: translationSettingsError,
} = useTranslationSettings({ entity_type: reference! })
const {
translations,
references,
fetchNextPage,
count,
isFetchingNextPage,
hasNextPage,
isPending,
isError,
error,
} = useReferenceTranslations(
reference!,
translatable_fields?.[reference!] ?? [],
referenceIdParam,
{
enabled: !!translatable_fields && !!reference,
placeholderData: keepPreviousData,
}
)
const {
store,
isPending: isStorePending,
isError: isStoreError,
error: storeError,
} = useStore()
const ready =
!isPending &&
!!translations &&
!!translatable_fields &&
!isTranslationSettingsPending &&
!!references &&
!isStorePending &&
!!store
if (isError || isStoreError || isTranslationSettingsError) {
throw error || storeError || translationSettingsError
}
return (
<RouteFocusModal prev={referenceIdParam.length ? -1 : ".."}>
{ready && (
<TranslationsEditForm
translations={translations}
references={references}
entityType={reference!}
availableLocales={store?.supported_locales ?? []}
translatableFields={translatable_fields[reference!]}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
referenceCount={count}
/>
)}
</RouteFocusModal>
)
}