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:
authored and GitHub committed 2025-12-17 13:36:50 +01:00
1 parent c1a5390fc6
commit 3d1330ebb9
69 files changed
+3595 -112

No files matched your search

@@ -4,6 +4,7 @@ import { PropsWithChildren } from "react"
type IconAvatarProps = PropsWithChildren<{ type IconAvatarProps = PropsWithChildren<{
className?: string className?: string
size?: "small" | "large" | "xlarge" size?: "small" | "large" | "xlarge"
variant?: "squared" | "rounded"
}> }>
/** /**
@@ -13,6 +14,7 @@ type IconAvatarProps = PropsWithChildren<{
*/ */
export const IconAvatar = ({ export const IconAvatar = ({
size = "small", size = "small",
variant = "rounded",
children, children,
className, className,
}: IconAvatarProps) => { }: IconAvatarProps) => {
@@ -20,6 +22,8 @@ export const IconAvatar = ({
<div <div
className={clx( className={clx(
"shadow-borders-base flex size-7 items-center justify-center", "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", "[&>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]": "size-7 rounded-md [&>div]:size-6 [&>div]:rounded-[4px]":
@@ -20,16 +20,20 @@ export const DataGridCellContainer = ({
errors, errors,
rowErrors, rowErrors,
outerComponent, outerComponent,
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => { isMultiLine,
}: DataGridCellContainerProps &
DataGridErrorRenderProps<any> & { isMultiLine?: boolean }) => {
const error = get(errors, field) const error = get(errors, field)
const hasError = !!error const hasError = !!error
return ( return (
<div className="group/container relative size-full"> <div className={clx("group/container relative h-full w-full")}>
<div <div
className={clx( 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": "bg-ui-tag-red-bg text-ui-tag-red-text":
hasError && !isAnchor && !isSelected && !isDragSelected, hasError && !isAnchor && !isSelected && !isDragSelected,
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor, "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}> <RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
{children} {children}
</RenderChildren> </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> DataGridCellProps<TData, TValue>
> & { > & {
color?: "muted" | "normal" color?: "muted" | "normal"
isMultiLine?: boolean
} }
export const DataGridReadonlyCell = <TData, TValue = any>({ export const DataGridReadonlyCell = <TData, TValue = any>({
context, context,
color = "muted", color = "muted",
children, children,
isMultiLine = false,
}: DataGridReadonlyCellProps<TData, TValue>) => { }: DataGridReadonlyCellProps<TData, TValue>) => {
const { rowErrors } = useDataGridCellError({ context }) const { rowErrors } = useDataGridCellError({ context })
return ( return (
<div <div
className={clx( 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 === "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} /> <DataGridRowErrorIndicator rowErrors={rowErrors} />
</div> </div>
) )
@@ -15,7 +15,11 @@ import {
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual" import {
VirtualItem,
Virtualizer,
useVirtualizer,
} from "@tanstack/react-virtual"
import React, { import React, {
CSSProperties, CSSProperties,
ReactNode, ReactNode,
@@ -51,7 +55,7 @@ import { isCellMatch, isSpecialFocusKey } from "../utils"
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal" import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
export interface DataGridRootProps< export interface DataGridRootProps<
TData, TData,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues
> { > {
data?: TData[] data?: TData[]
columns: ColumnDef<TData>[] columns: ColumnDef<TData>[]
@@ -60,6 +64,25 @@ export interface DataGridRootProps<
onEditingChange?: (isEditing: boolean) => void onEditingChange?: (isEditing: boolean) => void
disableInteractions?: boolean disableInteractions?: boolean
multiColumnSelection?: 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 const ROW_HEIGHT = 40
@@ -97,7 +120,7 @@ const getCommonPinningStyles = <TData,>(
export const DataGridRoot = < export const DataGridRoot = <
TData, TData,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues
>({ >({
data = [], data = [],
columns, columns,
@@ -106,7 +129,15 @@ export const DataGridRoot = <
onEditingChange, onEditingChange,
disableInteractions, disableInteractions,
multiColumnSelection = false, multiColumnSelection = false,
showColumnsDropdown = true,
totalRowCount,
onFetchMore,
isFetchingMore,
hasNextPage,
headerContent,
}: DataGridRootProps<TData, TFieldValues>) => { }: DataGridRootProps<TData, TFieldValues>) => {
// TODO: remove once everything is lazy loaded
const isLazyMode = totalRowCount !== undefined
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const { redo, undo, execute } = useCommandHistory() const { redo, undo, execute } = useCommandHistory()
@@ -163,10 +194,18 @@ export const DataGridRoot = <
) )
const visibleColumns = grid.getVisibleLeafColumns() const visibleColumns = grid.getVisibleLeafColumns()
const effectiveRowCount = isLazyMode ? totalRowCount! : visibleRows.length
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: visibleRows.length, count: effectiveRowCount,
estimateSize: () => ROW_HEIGHT, estimateSize: () => ROW_HEIGHT,
getScrollElement: () => containerRef.current, 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, overscan: 5,
rangeExtractor: (range) => { rangeExtractor: (range) => {
const toRender = new Set( const toRender = new Set(
@@ -189,6 +228,76 @@ export const DataGridRoot = <
}) })
const virtualRows = rowVirtualizer.getVirtualItems() 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({ const columnVirtualizer = useVirtualizer({
count: visibleColumns.length, count: visibleColumns.length,
estimateSize: (index) => visibleColumns[index].getSize(), estimateSize: (index) => visibleColumns[index].getSize(),
@@ -552,6 +661,7 @@ export const DataGridRoot = <
<DataGridContext.Provider value={values}> <DataGridContext.Provider value={values}>
<div className="bg-ui-bg-subtle flex size-full flex-col"> <div className="bg-ui-bg-subtle flex size-full flex-col">
<DataGridHeader <DataGridHeader
showColumnsDropdown={showColumnsDropdown}
columnOptions={columnOptions} columnOptions={columnOptions}
isDisabled={isColumsDisabled} isDisabled={isColumsDisabled}
onToggleColumn={handleToggleColumnVisibility} onToggleColumn={handleToggleColumnVisibility}
@@ -560,6 +670,7 @@ export const DataGridRoot = <
onResetColumns={handleResetColumns} onResetColumns={handleResetColumns}
isHighlighted={isHighlighted} isHighlighted={isHighlighted}
onHeaderInteractionChange={handleHeaderInteractionChange} onHeaderInteractionChange={handleHeaderInteractionChange}
headerContent={headerContent}
/> />
<div className="size-full overflow-hidden"> <div className="size-full overflow-hidden">
<div <div
@@ -650,6 +761,20 @@ export const DataGridRoot = <
> >
{virtualRows.map((virtualRow) => { {virtualRows.map((virtualRow) => {
const row = visibleRows[virtualRow.index] as Row<TData> 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) const rowIndex = flatRows.findIndex((r) => r.id === row.id)
return ( return (
@@ -658,6 +783,7 @@ export const DataGridRoot = <
row={row} row={row}
rowIndex={rowIndex} rowIndex={rowIndex}
virtualRow={virtualRow} virtualRow={virtualRow}
rowVirtualizer={rowVirtualizer}
flatColumns={flatColumns} flatColumns={flatColumns}
virtualColumns={virtualColumns} virtualColumns={virtualColumns}
anchor={anchor} anchor={anchor}
@@ -680,12 +806,14 @@ export const DataGridRoot = <
type DataGridHeaderProps = { type DataGridHeaderProps = {
columnOptions: GridColumnOption[] columnOptions: GridColumnOption[]
isDisabled: boolean isDisabled: boolean
showColumnsDropdown: boolean
onToggleColumn: (index: number) => (value: boolean) => void onToggleColumn: (index: number) => (value: boolean) => void
onResetColumns: () => void onResetColumns: () => void
isHighlighted: boolean isHighlighted: boolean
errorCount: number errorCount: number
onToggleErrorHighlighting: () => void onToggleErrorHighlighting: () => void
onHeaderInteractionChange: (isActive: boolean) => void onHeaderInteractionChange: (isActive: boolean) => void
headerContent?: ReactNode
} }
const DataGridHeader = ({ const DataGridHeader = ({
@@ -697,6 +825,8 @@ const DataGridHeader = ({
errorCount, errorCount,
onToggleErrorHighlighting, onToggleErrorHighlighting,
onHeaderInteractionChange, onHeaderInteractionChange,
showColumnsDropdown,
headerContent,
}: DataGridHeaderProps) => { }: DataGridHeaderProps) => {
const [shortcutsOpen, setShortcutsOpen] = useState(false) const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [columnsOpen, setColumnsOpen] = useState(false) const [columnsOpen, setColumnsOpen] = useState(false)
@@ -717,58 +847,61 @@ const DataGridHeader = ({
} }
return ( return (
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4"> <div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
<div className="flex items-center gap-x-2"> {showColumnsDropdown && (
<DropdownMenu <div className="flex items-center gap-x-2">
dir={direction} <DropdownMenu
open={columnsOpen} dir={direction}
onOpenChange={handleColumnsOpenChange} open={columnsOpen}
> onOpenChange={handleColumnsOpenChange}
<ConditionalTooltip
showTooltip={isDisabled}
content={t("dataGrid.columns.disabled")}
> >
<DropdownMenu.Trigger asChild disabled={isDisabled}> <ConditionalTooltip
<Button size="small" variant="secondary"> showTooltip={isDisabled}
{hasChanged ? <AdjustmentsDone /> : <Adjustments />} content={t("dataGrid.columns.disabled")}
{t("dataGrid.columns.view")} >
</Button> <DropdownMenu.Trigger asChild disabled={isDisabled}>
</DropdownMenu.Trigger> <Button size="small" variant="secondary">
</ConditionalTooltip> {hasChanged ? <AdjustmentsDone /> : <Adjustments />}
<DropdownMenu.Content> {t("dataGrid.columns.view")}
{columnOptions.map((column, index) => { </Button>
const { checked, disabled, id, name } = column </DropdownMenu.Trigger>
</ConditionalTooltip>
<DropdownMenu.Content>
{columnOptions.map((column, index) => {
const { checked, disabled, id, name } = column
if (disabled) { if (disabled) {
return null return null
} }
return ( return (
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key={id} key={id}
checked={checked} checked={checked}
onCheckedChange={onToggleColumn(index)} onCheckedChange={onToggleColumn(index)}
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
{name} {name}
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
) )
})} })}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
{hasChanged && ( {hasChanged && (
<Button <Button
size="small" size="small"
variant="transparent" variant="transparent"
type="button" type="button"
onClick={onResetColumns} onClick={onResetColumns}
className="text-ui-fg-muted hover:text-ui-fg-subtle" className="text-ui-fg-muted hover:text-ui-fg-subtle"
data-id="reset-columns" data-id="reset-columns"
> >
{t("dataGrid.columns.resetToDefault")} {t("dataGrid.columns.resetToDefault")}
</Button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-x-2"> )}
{headerContent}
<div className="ml-auto flex items-center gap-x-2">
{errorCount > 0 && ( {errorCount > 0 && (
<Button <Button
size="small" size="small"
@@ -832,11 +965,11 @@ const DataGridCell = <TData,>({
data-row-index={rowIndex} data-row-index={rowIndex}
data-column-index={columnIndex} data-column-index={columnIndex}
className={clx( 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} tabIndex={-1}
> >
<div className="relative h-full w-full"> <div className="relative w-full">
{flexRender(cell.column.columnDef.cell, { {flexRender(cell.column.columnDef.cell, {
...cell.getContext(), ...cell.getContext(),
columnIndex, columnIndex,
@@ -861,10 +994,11 @@ const DataGridCell = <TData,>({
type DataGridRowProps<TData> = { type DataGridRowProps<TData> = {
row: Row<TData> row: Row<TData>
rowIndex: number rowIndex: number
virtualRow: VirtualItem<Element> virtualRow: VirtualItem
rowVirtualizer: Virtualizer<HTMLDivElement, Element>
virtualPaddingLeft?: number virtualPaddingLeft?: number
virtualPaddingRight?: number virtualPaddingRight?: number
virtualColumns: VirtualItem<Element>[] virtualColumns: VirtualItem[]
flatColumns: Column<TData, unknown>[] flatColumns: Column<TData, unknown>[]
anchor: DataGridCoordinates | null anchor: DataGridCoordinates | null
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
@@ -875,6 +1009,7 @@ const DataGridRow = <TData,>({
row, row,
rowIndex, rowIndex,
virtualRow, virtualRow,
rowVirtualizer,
virtualPaddingLeft, virtualPaddingLeft,
virtualPaddingRight, virtualPaddingRight,
virtualColumns, virtualColumns,
@@ -889,10 +1024,12 @@ const DataGridRow = <TData,>({
<div <div
role="row" role="row"
aria-rowindex={virtualRow.index} aria-rowindex={virtualRow.index}
data-index={virtualRow.index}
ref={(node) => rowVirtualizer.measureElement(node)}
style={{ style={{
transform: `translateY(${virtualRow.start}px)`, 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 ? ( {virtualPaddingLeft ? (
<div <div
@@ -943,3 +1080,77 @@ const DataGridRow = <TData,>({
</div> </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 { clx } from "@medusajs/ui"
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form" import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs" import { useCombinedRefs } from "../../../hooks/use-combined-refs"
@@ -43,29 +43,29 @@ const Inner = ({
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
const [localValue, setLocalValue] = useState(value) const [localValue, setLocalValue] = useState(value)
const inputElRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
setLocalValue(value) setLocalValue(value)
}, [value]) }, [value])
const combinedRefs = useCombinedRefs(inputRef, ref) const combinedRefs = useCombinedRefs(inputRef, ref, inputElRef)
return ( return (
<input <input
className={clx( className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none", "txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer bg-transparent outline-none",
"focus:cursor-text" "focus:cursor-text",
"items-center justify-center"
)} )}
autoComplete="off" autoComplete="off"
tabIndex={-1} tabIndex={-1}
value={localValue} value={localValue ?? ""}
onChange={(e) => setLocalValue(e.target.value)} onChange={(e) => setLocalValue(e.target.value)}
ref={combinedRefs} ref={combinedRefs}
onBlur={() => { onBlur={() => {
onBlur() onBlur()
onInputBlur() onInputBlur()
// We propagate the change to the field only when the input is blurred
onChange(localValue, value) onChange(localValue, value)
}} }}
{...input} {...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 { DataGridBooleanCell } from "./data-grid-boolean-cell"
export { DataGridCurrencyCell } from "./data-grid-currency-cell" export { DataGridCurrencyCell } from "./data-grid-currency-cell"
export { DataGridMultilineCell } from "./data-grid-multiline-cell"
export { DataGridNumberCell } from "./data-grid-number-cell" export { DataGridNumberCell } from "./data-grid-number-cell"
export { DataGridReadonlyCell as DataGridReadOnlyCell } from "./data-grid-readonly-cell" export { DataGridReadonlyCell as DataGridReadOnlyCell } from "./data-grid-readonly-cell"
export { DataGridRoot, type DataGridRootProps } from "./data-grid-root" export { DataGridRoot, type DataGridRootProps } from "./data-grid-root"
export { DataGridSkeleton } from "./data-grid-skeleton" export { DataGridSkeleton } from "./data-grid-skeleton"
export { DataGridTextCell } from "./data-grid-text-cell" 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 { import {
DataGridBooleanCell, DataGridBooleanCell,
DataGridCurrencyCell, DataGridCurrencyCell,
DataGridMultilineCell,
DataGridNumberCell, DataGridNumberCell,
DataGridReadOnlyCell, DataGridReadOnlyCell,
DataGridRoot, DataGridRoot,
DataGridSkeleton, DataGridSkeleton,
DataGridTextCell, DataGridTextCell,
DataGridExpandableTextCell,
type DataGridRootProps, type DataGridRootProps,
} from "./components" } from "./components"
@@ -18,6 +20,11 @@ interface DataGridProps<TData, TFieldValues extends FieldValues = FieldValues>
const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({ const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
isLoading, isLoading,
// Lazy loading props - passed through to DataGridRoot
totalRowCount,
onFetchMore,
isFetchingMore,
hasNextPage,
...props ...props
}: DataGridProps<TData, TFieldValues>) => { }: DataGridProps<TData, TFieldValues>) => {
return isLoading ? ( 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, { export const DataGrid = Object.assign(_DataGrid, {
BooleanCell: DataGridBooleanCell, BooleanCell: DataGridBooleanCell,
TextCell: DataGridTextCell, TextCell: DataGridTextCell,
MultilineCell: DataGridMultilineCell,
ExpandableTextCell: DataGridExpandableTextCell,
NumberCell: DataGridNumberCell, NumberCell: DataGridNumberCell,
CurrencyCell: DataGridCurrencyCell, CurrencyCell: DataGridCurrencyCell,
ReadonlyCell: DataGridReadOnlyCell, ReadonlyCell: DataGridReadOnlyCell,
@@ -36,6 +36,18 @@ type DataGridHelperColumnsProps<TData, TFieldValues extends FieldValues> = {
* @default false * @default false
*/ */
disableHiding?: boolean 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> field: FieldFunction<TData, TFieldValues>
@@ -59,12 +71,18 @@ export function createDataGridHelper<
disableHiding = false, disableHiding = false,
field, field,
type, type,
size,
minSize,
maxSize,
}: DataGridHelperColumnsProps<TData, TFieldValues>) => }: DataGridHelperColumnsProps<TData, TFieldValues>) =>
columnHelper.display({ columnHelper.display({
id, id,
header, header,
cell, cell,
enableHiding: !disableHiding, enableHiding: !disableHiding,
size,
minSize,
maxSize,
meta: { meta: {
name, name,
field, field,
@@ -128,6 +128,7 @@ export const useDataGridCell = <TData, TValue>({
case "number": case "number":
return numberCharacterRegex.test(key) return numberCharacterRegex.test(key)
case "text": case "text":
case "multiline-text":
return textCharacterRegex.test(key) return textCharacterRegex.test(key)
default: default:
// KeyboardEvents should not be forwareded to other types of cells // KeyboardEvents should not be forwareded to other types of cells
@@ -180,6 +181,17 @@ export const useDataGridCell = <TData, TValue>({
)?.set )?.set
nativeInputValueSetter?.call(inputRef.current, e.key) 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 // Trigger input event to notify react-hook-form
const event = new Event("input", { bubbles: true }) const event = new Event("input", { bubbles: true })
inputRef.current.dispatchEvent(event) inputRef.current.dispatchEvent(event)
@@ -202,7 +214,7 @@ export const useDataGridCell = <TData, TValue>({
useEffect(() => { useEffect(() => {
if (isAnchor && !containerRef.current?.contains(document.activeElement)) { if (isAnchor && !containerRef.current?.contains(document.activeElement)) {
containerRef.current?.focus() containerRef.current?.focus({ preventScroll: true })
} }
}, [isAnchor]) }, [isAnchor])
@@ -228,6 +228,7 @@ export function convertArrayToPrimitive(
case "boolean": case "boolean":
return values.map(convertToBoolean) return values.map(convertToBoolean)
case "text": case "text":
case "multiline-text":
return values.map(covertToString) return values.map(covertToString)
default: default:
throw new Error(`Unsupported target type "${type}".`) throw new Error(`Unsupported target type "${type}".`)
@@ -211,7 +211,22 @@ export const useDataGridKeydownEvent = <
[rangeEnd, matrix, getSelectionValues, setSelectionValues, execute] [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) => { (anchor: DataGridCoordinates) => {
const field = matrix.getCellField(anchor) const field = matrix.getCellField(anchor)
const input = queryTool?.getInput(anchor) const input = queryTool?.getInput(anchor)
@@ -303,9 +318,12 @@ export const useDataGridKeydownEvent = <
case "togglable-number": case "togglable-number":
handleSpaceKeyTogglableNumber(anchor) handleSpaceKeyTogglableNumber(anchor)
break break
case "number":
case "text": case "text":
handleSpaceKeyTextOrNumber(anchor) case "multiline-text":
handleSpaceKeyText(anchor)
break
case "number":
handleSpaceKeyNumber(anchor)
break break
} }
}, },
@@ -314,7 +332,8 @@ export const useDataGridKeydownEvent = <
isEditing, isEditing,
matrix, matrix,
handleSpaceKeyBoolean, handleSpaceKeyBoolean,
handleSpaceKeyTextOrNumber, handleSpaceKeyText,
handleSpaceKeyNumber,
handleSpaceKeyTogglableNumber, handleSpaceKeyTogglableNumber,
] ]
) )
@@ -384,6 +403,30 @@ export const useDataGridKeydownEvent = <
[handleMoveOnEnter, handleEditOnEnter, isEditing] [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. * Handles the enter key for boolean cells.
* *
@@ -432,11 +475,18 @@ export const useDataGridKeydownEvent = <
return return
} }
e.preventDefault()
const type = matrix.getCellType(anchor) const type = matrix.getCellType(anchor)
if (type === "multiline-text" && isEditing && e.shiftKey) {
return
}
e.preventDefault()
switch (type) { switch (type) {
case "multiline-text":
handleEnterKeyMultilineText(e, anchor)
break
case "togglable-number": case "togglable-number":
case "text": case "text":
case "number": case "number":
@@ -448,7 +498,14 @@ export const useDataGridKeydownEvent = <
} }
} }
}, },
[anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean] [
anchor,
matrix,
isEditing,
handleEnterKeyTextOrNumber,
handleEnterKeyBoolean,
handleEnterKeyMultilineText,
]
) )
const handleDeleteKeyTogglableNumber = useCallback( const handleDeleteKeyTogglableNumber = useCallback(
@@ -526,6 +583,7 @@ export const useDataGridKeydownEvent = <
switch (type) { switch (type) {
case "text": case "text":
case "multiline-text":
case "number": case "number":
handleDeleteKeyTextOrNumber(anchor, rangeEnd) handleDeleteKeyTextOrNumber(anchor, rangeEnd)
break break
@@ -16,6 +16,7 @@ import {
export type DataGridColumnType = export type DataGridColumnType =
| "text" | "text"
| "multiline-text"
| "number" | "number"
| "boolean" | "boolean"
| "togglable-number" | "togglable-number"
@@ -9,6 +9,7 @@ import { useExtension } from "../../../providers/extension-provider"
import { INavItem, NavItem } from "../nav-item" import { INavItem, NavItem } from "../nav-item"
import { Shell } from "../shell" import { Shell } from "../shell"
import { UserMenu } from "../user-menu" import { UserMenu } from "../user-menu"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
export const SettingsLayout = () => { export const SettingsLayout = () => {
return ( return (
@@ -19,6 +20,7 @@ export const SettingsLayout = () => {
} }
const useSettingRoutes = (): INavItem[] => { const useSettingRoutes = (): INavItem[] => {
const isTranslationsEnabled = useFeatureFlag("translation")
const { t } = useTranslation() const { t } = useTranslation()
return useMemo( return useMemo(
@@ -63,8 +65,16 @@ const useSettingRoutes = (): INavItem[] => {
label: t("stockLocations.domain"), label: t("stockLocations.domain"),
to: "/settings/locations", 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" import { StackedModalProvider } from "../stacked-modal-provider"
type RouteFocusModalProps = PropsWithChildren<{ type RouteFocusModalProps = PropsWithChildren<{
prev?: string | Partial<Path> prev?: string | Partial<Path> | number
}> }>
const Root = ({ prev = "..", children }: RouteFocusModalProps) => { const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
@@ -16,7 +16,8 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [stackedModalOpen, onStackedModalOpen] = 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 * Open the modal when the component mounts. This
@@ -34,7 +35,11 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (!open) { if (!open) {
document.body.style.pointerEvents = "auto" document.body.style.pointerEvents = "auto"
navigate(to, { replace: true }) if (typeof to === "number") {
navigate(to)
} else {
navigate(to, { replace: true })
}
return return
} }
@@ -24,7 +24,12 @@ export const RouteModalForm = <TFieldValues extends FieldValues = any>({
} = form } = form
const blocker = useBlocker(({ currentLocation, nextLocation }) => { 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) { if (isSubmitSuccessful) {
onClose?.(true) onClose?.(true)
@@ -1,9 +1,9 @@
import { PropsWithChildren, useCallback, useMemo, useState } from "react" 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" import { RouteModalProviderContext } from "./route-modal-context"
type RouteModalProviderProps = PropsWithChildren<{ type RouteModalProviderProps = PropsWithChildren<{
prev: string | Partial<Path> prev: string | Partial<Path> | number
}> }>
export const RouteModalProvider = ({ export const RouteModalProvider = ({
@@ -11,15 +11,27 @@ export const RouteModalProvider = ({
children, children,
}: RouteModalProviderProps) => { }: RouteModalProviderProps) => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const [closeOnEscape, setCloseOnEscape] = useState(true) const [closeOnEscape, setCloseOnEscape] = useState(true)
const handleSuccess = useCallback( const handleSuccess = useCallback(
(path?: string) => { (path?: string) => {
const to = path || prev 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( 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 || []), ...(settingsRoutes?.[0]?.children || []),
], ],
}, },
@@ -1,7 +1,9 @@
import { FetchError } from "@medusajs/js-sdk" import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { import {
InfiniteData,
QueryKey, QueryKey,
UseInfiniteQueryOptions,
UseMutationOptions, UseMutationOptions,
UseQueryOptions, UseQueryOptions,
useMutation, useMutation,
@@ -11,6 +13,7 @@ import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client" import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { productsQueryKeys } from "./products" import { productsQueryKeys } from "./products"
import { useInfiniteList } from "../use-infinite-list"
const CATEGORIES_QUERY_KEY = "categories" as const const CATEGORIES_QUERY_KEY = "categories" as const
export const categoriesQueryKeys = queryKeysFactory(CATEGORIES_QUERY_KEY) export const categoriesQueryKeys = queryKeysFactory(CATEGORIES_QUERY_KEY)
@@ -58,6 +61,35 @@ export const useProductCategories = (
return { ...data, ...rest } 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 = ( export const useCreateProductCategory = (
options?: UseMutationOptions< options?: UseMutationOptions<
HttpTypes.AdminProductCategoryResponse, HttpTypes.AdminProductCategoryResponse,
@@ -1,7 +1,9 @@
import { FetchError } from "@medusajs/js-sdk" import { FetchError } from "@medusajs/js-sdk"
import { FindParams, HttpTypes, PaginatedResponse } from "@medusajs/types" import { FindParams, HttpTypes, PaginatedResponse } from "@medusajs/types"
import { import {
InfiniteData,
QueryKey, QueryKey,
UseInfiniteQueryOptions,
UseMutationOptions, UseMutationOptions,
UseQueryOptions, UseQueryOptions,
useMutation, useMutation,
@@ -11,6 +13,7 @@ import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client" import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { productsQueryKeys } from "./products" import { productsQueryKeys } from "./products"
import { useInfiniteList } from "../use-infinite-list"
const COLLECTION_QUERY_KEY = "collections" as const const COLLECTION_QUERY_KEY = "collections" as const
export const collectionsQueryKeys = queryKeysFactory(COLLECTION_QUERY_KEY) export const collectionsQueryKeys = queryKeysFactory(COLLECTION_QUERY_KEY)
@@ -57,6 +60,34 @@ export const useCollections = (
return { ...data, ...rest } 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 = ( export const useUpdateCollection = (
id: string, id: string,
options?: UseMutationOptions< options?: UseMutationOptions<
@@ -34,6 +34,7 @@ export * from "./store"
export * from "./tags" export * from "./tags"
export * from "./tax-rates" export * from "./tax-rates"
export * from "./tax-regions" export * from "./tax-regions"
export * from "./translations"
export * from "./users" export * from "./users"
export * from "./views" export * from "./views"
export * from "./workflow-executions" export * from "./workflow-executions"
@@ -1,7 +1,9 @@
import { FetchError } from "@medusajs/js-sdk" import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { import {
InfiniteData,
QueryKey, QueryKey,
UseInfiniteQueryOptions,
UseMutationOptions, UseMutationOptions,
UseQueryOptions, UseQueryOptions,
useMutation, useMutation,
@@ -10,6 +12,7 @@ import {
import { sdk } from "../../lib/client" import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client" import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { useInfiniteList } from "../use-infinite-list"
const PRODUCT_TYPES_QUERY_KEY = "product_types" as const const PRODUCT_TYPES_QUERY_KEY = "product_types" as const
export const productTypesQueryKeys = queryKeysFactory(PRODUCT_TYPES_QUERY_KEY) export const productTypesQueryKeys = queryKeysFactory(PRODUCT_TYPES_QUERY_KEY)
@@ -57,6 +60,35 @@ export const useProductTypes = (
return { ...data, ...rest } 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 = ( export const useCreateProductType = (
options?: UseMutationOptions< options?: UseMutationOptions<
HttpTypes.AdminProductTypeResponse, 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 { sdk } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { FetchError } from "@medusajs/js-sdk" 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 const PRODUCT_VARIANT_QUERY_KEY = "product_variant" as const
export const productVariantQueryKeys = queryKeysFactory( export const productVariantQueryKeys = queryKeysFactory(
@@ -9,9 +17,14 @@ export const productVariantQueryKeys = queryKeysFactory(
) )
export const useVariants = ( export const useVariants = (
query?: Record<string, any>, query?: HttpTypes.AdminProductVariantParams,
options?: Omit< options?: Omit<
UseQueryOptions<any, FetchError, any, QueryKey>, UseQueryOptions<
HttpTypes.AdminProductVariantListResponse,
FetchError,
HttpTypes.AdminProductVariantListResponse,
QueryKey
>,
"queryFn" | "queryKey" "queryFn" | "queryKey"
> >
) => { ) => {
@@ -23,3 +36,34 @@ export const useVariants = (
return { ...data, ...rest } 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 { HttpTypes } from "@medusajs/types"
import { import {
QueryKey, QueryKey,
UseInfiniteQueryOptions,
useMutation, useMutation,
UseMutationOptions, UseMutationOptions,
useQuery, useQuery,
UseQueryOptions, UseQueryOptions,
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { InfiniteData } from "@tanstack/query-core"
import { sdk } from "../../lib/client" import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client" import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { inventoryItemsQueryKeys } from "./inventory.tsx" import { inventoryItemsQueryKeys } from "./inventory.tsx"
import { useInfiniteList } from "../use-infinite-list.tsx"
const PRODUCTS_QUERY_KEY = "products" as const const PRODUCTS_QUERY_KEY = "products" as const
export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY) export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY)
@@ -310,6 +313,32 @@ export const useProducts = (
return { ...data, ...rest } 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 = ( export const useCreateProduct = (
options?: UseMutationOptions< options?: UseMutationOptions<
HttpTypes.AdminProductResponse, HttpTypes.AdminProductResponse,
@@ -1,7 +1,9 @@
import { FetchError } from "@medusajs/js-sdk" import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { import {
InfiniteData,
QueryKey, QueryKey,
UseInfiniteQueryOptions,
UseMutationOptions, UseMutationOptions,
UseQueryOptions, UseQueryOptions,
useMutation, useMutation,
@@ -10,6 +12,7 @@ import {
import { sdk } from "../../lib/client" import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client" import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory" import { queryKeysFactory } from "../../lib/query-key-factory"
import { useInfiniteList } from "../use-infinite-list"
const TAGS_QUERY_KEY = "tags" as const const TAGS_QUERY_KEY = "tags" as const
export const productTagsQueryKeys = queryKeysFactory(TAGS_QUERY_KEY) export const productTagsQueryKeys = queryKeysFactory(TAGS_QUERY_KEY)
@@ -57,6 +60,35 @@ export const useProductTags = (
return { ...data, ...rest } 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 = ( export const useCreateProductTag = (
query?: HttpTypes.AdminProductTagParams, query?: HttpTypes.AdminProductTagParams,
options?: UseMutationOptions< 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": { "search": {
"type": "string" "type": "string"
}, },
"original": {
"type": "string"
},
"of": { "of": {
"type": "string" "type": "string"
}, },
@@ -104,6 +107,9 @@
"removed": { "removed": {
"type": "string" "type": "string"
}, },
"remaining": {
"type": "string"
},
"admin": { "admin": {
"type": "string" "type": "string"
}, },
@@ -191,6 +197,7 @@
"apply", "apply",
"range", "range",
"search", "search",
"original",
"of", "of",
"results", "results",
"pages", "pages",
@@ -213,6 +220,7 @@
"modified", "modified",
"added", "added",
"removed", "removed",
"remaining",
"admin", "admin",
"store", "store",
"details", "details",
@@ -381,6 +389,12 @@
"save": { "save": {
"type": "string" "type": "string"
}, },
"saveChanges": {
"type": "string"
},
"saveAndClose": {
"type": "string"
},
"saveAsDraft": { "saveAsDraft": {
"type": "string" "type": "string"
}, },
@@ -513,7 +527,9 @@
}, },
"required": [ "required": [
"save", "save",
"saveChanges",
"saveAsDraft", "saveAsDraft",
"saveAndClose",
"copy", "copy",
"copied", "copied",
"duplicate", "duplicate",
@@ -9328,6 +9344,112 @@
], ],
"additionalProperties": false "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": { "store": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11,6 +11,7 @@
"apply": "Apply", "apply": "Apply",
"range": "Range", "range": "Range",
"search": "Search", "search": "Search",
"original": "Original",
"of": "of", "of": "of",
"results": "results", "results": "results",
"pages": "pages", "pages": "pages",
@@ -33,6 +34,7 @@
"modified": "Modified", "modified": "Modified",
"added": "Added", "added": "Added",
"removed": "Removed", "removed": "Removed",
"remaining": "Left",
"admin": "Admin", "admin": "Admin",
"store": "Store", "store": "Store",
"details": "Details", "details": "Details",
@@ -99,7 +101,9 @@
}, },
"actions": { "actions": {
"save": "Save", "save": "Save",
"saveChanges": "Save changes",
"saveAsDraft": "Save as draft", "saveAsDraft": "Save as draft",
"saveAndClose": "Save and close",
"copy": "Copy", "copy": "Copy",
"copied": "Copied", "copied": "Copied",
"duplicate": "Duplicate", "duplicate": "Duplicate",
@@ -2504,6 +2508,40 @@
"deleteUserSuccess": "User {{name}} deleted successfully", "deleteUserSuccess": "User {{name}} deleted successfully",
"invite": "Invite" "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": { "store": {
"domain": "Store", "domain": "Store",
"manageYourStoresDetails": "Manage your store's details", "manageYourStoresDetails": "Manage your store's details",
@@ -11,6 +11,7 @@
"apply": "Aplicar", "apply": "Aplicar",
"range": "Rango", "range": "Rango",
"search": "Buscar", "search": "Buscar",
"original": "Original",
"of": "de", "of": "de",
"results": "resultados", "results": "resultados",
"pages": "páginas", "pages": "páginas",
@@ -33,6 +34,7 @@
"modified": "Modificado", "modified": "Modificado",
"added": "Agregado", "added": "Agregado",
"removed": "Eliminado", "removed": "Eliminado",
"remaining": "Restantes",
"admin": "Administrador", "admin": "Administrador",
"store": "Tienda", "store": "Tienda",
"details": "Detalles", "details": "Detalles",
@@ -120,7 +122,9 @@
}, },
"actions": { "actions": {
"save": "Guardar", "save": "Guardar",
"saveChanges": "Guardar cambios",
"saveAsDraft": "Guardar como borrador", "saveAsDraft": "Guardar como borrador",
"saveAndClose": "Guardar y cerrar",
"copy": "Copiar", "copy": "Copiar",
"copied": "Copiado", "copied": "Copiado",
"duplicate": "Duplicar", "duplicate": "Duplicar",
@@ -2443,6 +2447,40 @@
"deleteUserSuccess": "Usuario {{name}} eliminado correctamente", "deleteUserSuccess": "Usuario {{name}} eliminado correctamente",
"invite": "Invitar" "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": { "store": {
"domain": "Tienda", "domain": "Tienda",
"manageYourStoresDetails": "Gestiona los detalles de tu 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 { HttpTypes } from "@medusajs/types"
import { Container, Heading, StatusBadge, Text } from "@medusajs/ui" import { Container, Heading, StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action" import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
import { getIsActiveProps, getIsInternalProps } from "../../../common/utils" import { getIsActiveProps, getIsInternalProps } from "../../../common/utils"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type CategoryGeneralSectionProps = { type CategoryGeneralSectionProps = {
category: HttpTypes.AdminProductCategory category: HttpTypes.AdminProductCategory
@@ -14,6 +15,7 @@ export const CategoryGeneralSection = ({
category, category,
}: CategoryGeneralSectionProps) => { }: CategoryGeneralSectionProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const isTranslationsEnabled = useFeatureFlag("translation")
const activeProps = getIsActiveProps(category.is_active, t) const activeProps = getIsActiveProps(category.is_active, t)
const internalProps = getIsInternalProps(category.is_internal, 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: [ actions: [
{ {
@@ -1,4 +1,4 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { AdminProductCategoryResponse } from "@medusajs/types" import { AdminProductCategoryResponse } from "@medusajs/types"
import { Button, Container, Heading, Text } from "@medusajs/ui" import { Button, Container, Heading, Text } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query" 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 { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
import { useCategoryTableColumns } from "./use-category-table-columns" import { useCategoryTableColumns } from "./use-category-table-columns"
import { useCategoryTableQuery } from "./use-category-table-query" import { useCategoryTableQuery } from "./use-category-table-query"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -105,6 +106,7 @@ const CategoryRowActions = ({
category: AdminProductCategoryResponse["product_category"] category: AdminProductCategoryResponse["product_category"]
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const isTranslationsEnabled = useFeatureFlag("translation")
const handleDelete = useDeleteProductCategoryAction(category) const handleDelete = useDeleteProductCategoryAction(category)
return ( 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: [ actions: [
{ {
@@ -1,10 +1,11 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { Container, Heading, Text, usePrompt } from "@medusajs/ui" import { Container, Heading, Text, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteCollection } from "../../../../../hooks/api/collections" import { useDeleteCollection } from "../../../../../hooks/api/collections"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type CollectionGeneralSectionProps = { type CollectionGeneralSectionProps = {
collection: HttpTypes.AdminCollection collection: HttpTypes.AdminCollection
@@ -16,6 +17,7 @@ export const CollectionGeneralSection = ({
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const navigate = useNavigate() const navigate = useNavigate()
const isTranslationsEnabled = useFeatureFlag("translation")
const { mutateAsync } = useDeleteCollection(collection.id!) 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: [ actions: [
{ {
@@ -1,10 +1,11 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { usePrompt } from "@medusajs/ui" import { usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteCollection } from "../../../../../hooks/api/collections" import { useDeleteCollection } from "../../../../../hooks/api/collections"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
export const CollectionRowActions = ({ export const CollectionRowActions = ({
collection, collection,
@@ -13,6 +14,7 @@ export const CollectionRowActions = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const isTranslationsEnabled = useFeatureFlag("translation")
const { mutateAsync } = useDeleteCollection(collection.id!) 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: [ actions: [
{ {
@@ -1,9 +1,10 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui" import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action" import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type ProductTagGeneralSectionProps = { type ProductTagGeneralSectionProps = {
productTag: HttpTypes.AdminProductTag productTag: HttpTypes.AdminProductTag
@@ -14,6 +15,7 @@ export const ProductTagGeneralSection = ({
}: ProductTagGeneralSectionProps) => { }: ProductTagGeneralSectionProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const handleDelete = useDeleteProductTagAction({ productTag }) const handleDelete = useDeleteProductTagAction({ productTag })
const isTranslationsEnabled = useFeatureFlag("translation")
return ( return (
<Container className="flex items-center justify-between"> <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: [ actions: [
{ {
@@ -1,4 +1,4 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { Button, Container, Heading } from "@medusajs/ui" import { Button, Container, Heading } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query" import { keepPreviousData } from "@tanstack/react-query"
@@ -16,6 +16,7 @@ import { useProductTagTableQuery } from "../../../../../hooks/table/query"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action" import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
import { productTagListLoader } from "../../loader" import { productTagListLoader } from "../../loader"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -88,6 +89,7 @@ const ProductTagRowActions = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const handleDelete = useDeleteProductTagAction({ productTag }) const handleDelete = useDeleteProductTagAction({ productTag })
const isTranslationsEnabled = useFeatureFlag("translation")
return ( return (
<ActionMenu <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: [ actions: [
{ {
@@ -1,9 +1,10 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui" import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action" import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type ProductTypeGeneralSectionProps = { type ProductTypeGeneralSectionProps = {
productType: HttpTypes.AdminProductType productType: HttpTypes.AdminProductType
@@ -17,6 +18,7 @@ export const ProductTypeGeneralSection = ({
productType.id, productType.id,
productType.value productType.value
) )
const isTranslationsEnabled = useFeatureFlag("translation")
return ( return (
<Container className="flex items-center justify-between"> <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: [ actions: [
{ {
@@ -1,8 +1,9 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action" import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type ProductTypeRowActionsProps = { type ProductTypeRowActionsProps = {
productType: HttpTypes.AdminProductType productType: HttpTypes.AdminProductType
@@ -16,6 +17,7 @@ export const ProductTypeRowActions = ({
productType.id, productType.id,
productType.value productType.value
) )
const isTranslationsEnabled = useFeatureFlag("translation")
return ( return (
<ActionMenu <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: [ 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 { HttpTypes } from "@medusajs/types"
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui" import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@@ -7,6 +7,7 @@ import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section" import { SectionRow } from "../../../../../components/common/section"
import { useDeleteVariant } from "../../../../../hooks/api/products" import { useDeleteVariant } from "../../../../../hooks/api/products"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type VariantGeneralSectionProps = { type VariantGeneralSectionProps = {
variant: HttpTypes.AdminProductVariant variant: HttpTypes.AdminProductVariant
@@ -16,6 +17,7 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const navigate = useNavigate() const navigate = useNavigate()
const isTranslationsEnabled = useFeatureFlag("translation")
const hasInventoryKit = variant.inventory?.length > 1 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: [ actions: [
{ {
@@ -1,4 +1,4 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { Container, Heading, StatusBadge, toast, usePrompt } from "@medusajs/ui" import { Container, Heading, StatusBadge, toast, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@@ -8,6 +8,7 @@ import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section" import { SectionRow } from "../../../../../components/common/section"
import { useDeleteProduct } from "../../../../../hooks/api/products" import { useDeleteProduct } from "../../../../../hooks/api/products"
import { useExtension } from "../../../../../providers/extension-provider" import { useExtension } from "../../../../../providers/extension-provider"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
const productStatusColor = (status: string) => { const productStatusColor = (status: string) => {
switch (status) { switch (status) {
@@ -35,6 +36,7 @@ export const ProductGeneralSection = ({
const prompt = usePrompt() const prompt = usePrompt()
const navigate = useNavigate() const navigate = useNavigate()
const { getDisplays } = useExtension() const { getDisplays } = useExtension()
const isTranslationsEnabled = useFeatureFlag("translation")
const displays = getDisplays("product", "general") 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: [ 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 { HttpTypes } from "@medusajs/types"
import { import {
Badge, Badge,
@@ -26,6 +32,7 @@ import {
import { useQueryParams } from "../../../../../hooks/use-query-params" import { useQueryParams } from "../../../../../hooks/use-query-params"
import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants" import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants"
import { Thumbnail } from "../../../../../components/common/thumbnail" import { Thumbnail } from "../../../../../components/common/thumbnail"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type ProductVariantSectionProps = { type ProductVariantSectionProps = {
product: HttpTypes.AdminProduct product: HttpTypes.AdminProduct
@@ -38,6 +45,7 @@ export const ProductVariantSection = ({
product, product,
}: ProductVariantSectionProps) => { }: ProductVariantSectionProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const isTranslationsEnabled = useFeatureFlag("translation")
const { q, order, offset, allow_backorder, manage_inventory } = const { q, order, offset, allow_backorder, manage_inventory } =
useQueryParams( useQueryParams(
@@ -70,6 +78,11 @@ export const ProductVariantSection = ({
} }
) )
const translationParams = new URLSearchParams()
variants?.forEach((variant) => {
translationParams.append("reference_id", variant.id)
})
if (isError) { if (isError) {
throw error throw error
} }
@@ -115,6 +128,15 @@ export const ProductVariantSection = ({
to: `stock`, to: `stock`,
icon: <Buildings />, 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>[] = 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 { Button, Container, Heading, toast, usePrompt } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query" import { keepPreviousData } from "@tanstack/react-query"
import { createColumnHelper } from "@tanstack/react-table" import { createColumnHelper } from "@tanstack/react-table"
@@ -110,6 +110,7 @@ const ProductActions = ({ product }: { product: HttpTypes.AdminProduct }) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const { mutateAsync } = useDeleteProduct(product.id) const { mutateAsync } = useDeleteProduct(product.id)
const isTranslationsEnabled = useFeatureFlag("translation")
const handleDelete = async () => { const handleDelete = async () => {
const res = await prompt({ 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: [ 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>
)
}
@@ -61,7 +61,7 @@ export const validateTranslationsStep = createStep(
const unsupportedLocales = normalizedInput const unsupportedLocales = normalizedInput
.filter((translation) => Boolean(translation.locale_code)) .filter((translation) => Boolean(translation.locale_code))
.map((translation) => translation.locale_code) .map((translation) => translation.locale_code)
.filter((locale) => !enabledLocales.includes(locale ?? "")) .filter((locale) => !enabledLocales.includes(locale))
if (unsupportedLocales.length) { if (unsupportedLocales.length) {
throw new MedusaError( throw new MedusaError(
+6
View File
@@ -46,6 +46,7 @@ import { Views } from "./views"
import { WorkflowExecution } from "./workflow-execution" import { WorkflowExecution } from "./workflow-execution"
import { ShippingOptionType } from "./shipping-option-type" import { ShippingOptionType } from "./shipping-option-type"
import { Locale } from "./locale" import { Locale } from "./locale"
import { Translation } from "./translation"
export class Admin { export class Admin {
/** /**
@@ -225,6 +226,10 @@ export class Admin {
* @tags tax * @tags tax
*/ */
public taxProvider: TaxProvider public taxProvider: TaxProvider
/**
* @tags translations
*/
public translation: Translation
/** /**
* @tags promotion * @tags promotion
*/ */
@@ -268,6 +273,7 @@ export class Admin {
this.claim = new Claim(client) this.claim = new Claim(client)
this.taxRate = new TaxRate(client) this.taxRate = new TaxRate(client)
this.taxRegion = new TaxRegion(client) this.taxRegion = new TaxRegion(client)
this.translation = new Translation(client)
this.store = new Store(client) this.store = new Store(client)
this.productTag = new ProductTag(client) this.productTag = new ProductTag(client)
this.user = new User(client) this.user = new User(client)
@@ -0,0 +1,206 @@
import { HttpTypes } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class Translation {
/**
* @ignore
*/
private client: Client
/**
* @ignore
*/
constructor(client: Client) {
this.client = client
}
/**
* This method retrieves a paginated list of translations. It sends a request to the
* [List Translations](https://docs.medusajs.com/api/admin#translations_gettranslations)
* API route.
*
* @param query - Filters and pagination configurations.
* @param headers - Headers to pass in the request.
* @returns The paginated list of translations.
*
* @example
* To retrieve the list of translations:
*
* ```ts
* sdk.admin.translation.list()
* .then(({ translations, count, limit, offset }) => {
* console.log(translations)
* })
* ```
*
* To configure the pagination, pass the `limit` and `offset` query parameters.
*
* For example, to retrieve only 10 items and skip 10 items:
*
* ```ts
* sdk.admin.translation.list({
* limit: 10,
* offset: 10
* })
* .then(({ translations, count, limit, offset }) => {
* console.log(translations)
* })
* ```
*
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
* in each translation:
*
* ```ts
* sdk.admin.translation.list({
* fields: "id,name"
* })
* .then(({ translations, count, limit, offset }) => {
* console.log(translations)
* })
* ```
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async list(
query?: HttpTypes.AdminTranslationsListParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminTranslationsListResponse>(
`/admin/translations`,
{
headers,
query,
}
)
}
/**
* This method allows bulk operations on translations. It sends a request to the
* [Manage Translations](https://docs.medusajs.com/api/admin#translations_posttranslationsbatch)
* API route.
*
* @param payload - The translations to create, update, or delete.
* @param headers - Headers to pass in the request.
* @returns The translations' details.
*
* @example
* sdk.admin.translation.batch({
* create: [
* {
* reference_id: "prod_123",
* reference: "product",
* locale_code: "en-US",
* translations: { title: "Shirt" }
* }
* ],
* update: [
* {
* id: "trans_123",
* translations: { title: "Pants" }
* }
* ],
* delete: ["trans_321"]
* })
* .then(({ created, updated, deleted }) => {
* console.log(created, updated, deleted)
* })
* ```
*/
async batch(body: HttpTypes.AdminBatchTranslations, headers?: ClientHeaders) {
return await this.client.fetch<HttpTypes.AdminTranslationsBatchResponse>(
`/admin/translations/batch`,
{
method: "POST",
headers,
body,
}
)
}
/**
* This method retrieves the settings for the translations for a given entity type or all entity types if no entity type is provided.
* It sends a request to the
* [Get Translation Settings](https://docs.medusajs.com/api/admin#translations_gettranslationssettings) API route.
*
* @param query - The query parameters which can optionally include the entity type to get the settings for.
* @param headers - Headers to pass in the request.
* @returns The translation settings.
*
* @example
* To retrieve the settings for the translations for a given entity type:
*
* ```ts
* sdk.admin.translation.settings({
* entity_type: "product"
* })
* .then(({ translatable_fields }) => {
* console.log(translatable_fields)
* })
* ```
*
* To retrieve the settings for all entity types:
*
* ```ts
* sdk.admin.translation.settings()
* .then(({ translatable_fields }) => {
* console.log(translatable_fields)
* })
* ```
*/
async settings(
query?: HttpTypes.AdminTranslationSettingsParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminTranslationSettingsResponse>(
`/admin/translations/settings`,
{
headers,
query,
}
)
}
/**
* This method retrieves the statistics for the translations for a given entity type or all entity types if no entity type is provided.
* It sends a request to the
* [Get Translation Statistics](https://docs.medusajs.com/api/admin#translations_gettranslationsstatistics) API route.
*
* @param query - The query parameters which can optionally include the entity type to get the statistics for.
* @param headers - Headers to pass in the request.
* @returns The translation statistics.
*
* @example
* To retrieve the statistics for the translations for a given entity type:
*
* ```ts
* sdk.admin.translation.statistics({
* entity_type: "product"
* })
* .then(({ statistics }) => {
* console.log(statistics)
* })
* ```
*
* To retrieve the statistics for all entity types:
*
* ```ts
* sdk.admin.translation.statistics()
* .then(({ statistics }) => {
* console.log(statistics)
* })
* ```
*/
async statistics(
query?: HttpTypes.AdminTranslationStatisticsParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminTranslationStatisticsResponse>(
`/admin/translations/statistics`,
{
headers,
query,
}
)
}
}
@@ -1,3 +1,9 @@
import { BaseCollection } from "../common" import { BaseCollection } from "../common"
import { AdminTranslation } from "../../translations"
export interface AdminCollection extends BaseCollection {} export interface AdminCollection extends BaseCollection {
/**
* The collection's translations.
*/
translations?: AdminTranslation[] | null
}
@@ -1,4 +1,5 @@
import { AdminProduct } from "../../product" import { AdminProduct } from "../../product"
import { AdminTranslation } from "../../translations"
import { BaseProductCategory } from "../common" import { BaseProductCategory } from "../common"
export interface AdminProductCategory export interface AdminProductCategory
@@ -18,4 +19,8 @@ export interface AdminProductCategory
* The products that belong to this category. * The products that belong to this category.
*/ */
products?: AdminProduct[] products?: AdminProduct[]
/**
* The category's translations.
*/
translations?: AdminTranslation[] | null
} }
@@ -1,3 +1,9 @@
import { BaseProductTag } from "../common" import { BaseProductTag } from "../common"
import { AdminTranslation } from "../../translations"
export interface AdminProductTag extends BaseProductTag {} export interface AdminProductTag extends BaseProductTag {
/**
* The tag's translations.
*/
translations?: AdminTranslation[] | null
}
@@ -1,3 +1,9 @@
import { AdminTranslation } from "../../translations"
import { BaseProductType } from "../common" import { BaseProductType } from "../common"
export interface AdminProductType extends BaseProductType {} export interface AdminProductType extends BaseProductType {
/**
* The product type's translations.
*/
translations?: AdminTranslation[] | null
}
@@ -6,6 +6,7 @@ import { AdminProductTag } from "../../product-tag"
import { AdminProductType } from "../../product-type" import { AdminProductType } from "../../product-type"
import { AdminSalesChannel } from "../../sales-channel" import { AdminSalesChannel } from "../../sales-channel"
import { AdminShippingProfile } from "../../shipping-profile" import { AdminShippingProfile } from "../../shipping-profile"
import { AdminTranslation } from "../../translations"
import { import {
BaseProduct, BaseProduct,
BaseProductImage, BaseProductImage,
@@ -55,6 +56,10 @@ export interface AdminProductVariant extends BaseProductVariant {
* The product that this variant belongs to. * The product that this variant belongs to.
*/ */
product?: AdminProduct | null product?: AdminProduct | null
/**
* The product variant's translations.
*/
translations?: AdminTranslation[] | null
/** /**
* The variant's inventory items. * The variant's inventory items.
*/ */
@@ -108,6 +113,10 @@ export interface AdminProduct
* The product's variants. * The product's variants.
*/ */
variants: AdminProductVariant[] | null variants: AdminProductVariant[] | null
/**
* The product's translations.
*/
translations?: AdminTranslation[] | null
/** /**
* The product's type. * The product's type.
*/ */
@@ -1,3 +1,4 @@
export * from "./queries" export * from "./queries"
export * from "./responses" export * from "./responses"
export * from "./entities" export * from "./entities"
export * from "./payloads"
@@ -0,0 +1,44 @@
interface AdminCreateTranslation {
/**
* The ID of the entity being translated.
*/
reference_id: string
/**
* The type of entity being translated (e.g., "product", "product_variant").
*/
reference: string
/**
* The BCP 47 language tag code for this translation (e.g., "en-US", "fr-FR").
*/
locale_code: string
/**
* The translated fields as key-value pairs.
*/
translations: Record<string, unknown>
}
interface AdminUpdateTranslation {
/**
* The ID of the translation.
*/
id: string
/**
* The translated fields as key-value pairs.
*/
translations: Record<string, unknown>
}
export interface AdminBatchTranslations {
/**
* The translations to create.
*/
create?: AdminCreateTranslation[]
/**
* The translations to update.
*/
update?: AdminUpdateTranslation[]
/**
* The translations to delete.
*/
delete?: string[]
}
@@ -51,3 +51,13 @@ export interface AdminTranslationStatisticsParams {
*/ */
entity_types: string[] entity_types: string[]
} }
/**
* Query parameters for translation settings endpoint.
*/
export interface AdminTranslationSettingsParams {
/**
* The entity type to get the settings for (e.g., "product").
*/
entity_type?: string
}
@@ -29,6 +29,7 @@ export const allowed = [
"category_children", "category_children",
"parent_category", "parent_category",
"products", "products",
"translations",
] ]
export const retrieveProductCategoryConfig = { export const retrieveProductCategoryConfig = {
@@ -6,6 +6,7 @@ import {
import { import {
AdminBatchTranslations, AdminBatchTranslations,
AdminGetTranslationsParams, AdminGetTranslationsParams,
AdminTranslationSettingsParams,
AdminTranslationStatistics, AdminTranslationStatistics,
} from "./validators" } from "./validators"
import * as QueryConfig from "./query-config" import * as QueryConfig from "./query-config"
@@ -38,6 +39,8 @@ export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [
{ {
method: ["GET"], method: ["GET"],
matcher: "/admin/translations/settings", matcher: "/admin/translations/settings",
middlewares: [], middlewares: [
validateAndTransformQuery(AdminTranslationSettingsParams, {}),
],
}, },
] ]
@@ -9,19 +9,25 @@ import {
Modules, Modules,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import TranslationFeatureFlag from "../../../../feature-flags/translation" import TranslationFeatureFlag from "../../../../feature-flags/translation"
import { AdminTranslationSettingsParamsType } from "../validators"
/** /**
* @since 2.12.3 * @since 2.12.3
* @featureFlag translation * @featureFlag translation
*/ */
export const GET = async ( export const GET = async (
req: AuthenticatedMedusaRequest, req: AuthenticatedMedusaRequest<
undefined,
AdminTranslationSettingsParamsType
>,
res: MedusaResponse<HttpTypes.AdminTranslationSettingsResponse> res: MedusaResponse<HttpTypes.AdminTranslationSettingsResponse>
) => { ) => {
const translationService = req.scope.resolve<ITranslationModuleService>( const translationService = req.scope.resolve<ITranslationModuleService>(
Modules.TRANSLATION Modules.TRANSLATION
) )
const translatable_fields = translationService.getTranslatableFields() const translatable_fields = translationService.getTranslatableFields(
req.validatedQuery.entity_type
)
res.json({ res.json({
translatable_fields, translatable_fields,
@@ -64,3 +64,10 @@ export const AdminTranslationStatistics = z
? data.entity_types ? data.entity_types
: [data.entity_types], : [data.entity_types],
})) }))
export type AdminTranslationSettingsParamsType = z.infer<
typeof AdminTranslationSettingsParams
>
export const AdminTranslationSettingsParams = z.object({
entity_type: z.string().optional(),
})