feat: Translations UI (#14217)

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

* Translation list component

* Add translations namespace to js-sdk

* Translations hook

* Avoid incorrectly throwing when updating and locale not included

* Translations bulk editor component v1

* Add batch method to translations namespace in js-sdk

* Protect translations edit route with feature flag

* Handle reference_id search param

* Replace entity_type entity_id for reference reference_id

* Manage translations from product detail page

* Dynamically resolve base hook for retrieving translations

* Fix navigation from outside settings/translations

* Navigation to bulk editor from product list

* Add Translations to various product module types

* Type useVariants hook

* Handle product module entities translations in bulk editor

* Fix categories issue in datagrid due to column clash

* Translations bulk navigation from remaining entities detail pages

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

* Expandable text cell v1

* Popover approach

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

* Make popover more aligned to excell approach

* Correctly tie the focused cell anchor to popover

* Rework translations main component UI

* Fix link def export

* Swap axis for translations datagrid

* Add original column to translations data grid

* Remove is_default store locale from backend

* Remove ldefault locale from ui

* Type

* Add changeset

* Comments

* Remove unused import

* Add translations to admin product categories endpoint allowed fields

* Default locale removal

* Lazy loading with infinite scroll data grid

* Infinite list hook and implementation for products and variants

* Translation bulk editor lazy loaded datagrid

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

* Confgiure placeholder data

* Cleanup logs and refactor

* Infinite query hooks for translatable entities

* Batch requests for translation batch endpoint

* Clean up

* Update icon

* Add query param validator in settings endpoint

* Settings endpoint param type

* JS sdk methods for translation settings and statistics

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

* Resolve translation aggregate completion dynamically

* Format label

* Resolve bulk editor header label dynamically

* Include type and collection in translations config

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

* Translations

* Make translations bulk editor content columns wider

* Disable hiding Original column in translations bulk editor

* Adjust translations completion styles

* Fix translations config screen

* Locale selector switcher with conditional locale column rendering

* Batch one locale at a time

* Hooks save actions to footer buttons

* Reset dirty state on save

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

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

* more padding to avoid unnecessary horizontal scrollbar

* Update statistics graphs

* Translations

* Statistics graphs translations

* Translation, text sizes and weight in stat graphs

* Conditionally show/hide column visibility dropdown in datagrid

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

* Center text regardless of multiLine config

* Apply full height to datagrid cell regardles of multiSelect config

* Colors and fonts

* Handle key down for text area in text cell

* MultilineCell with special keydown handling

* Rework form schema to match new single locale edit flow

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

* Handle space key for text cells

* Finish hooking up multiline cell with key and mouse events

* Disable remaining buttons when batch is ongoing

* Style updates

* Update style

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

* Update styles

* Bars and labels alignment

* Add languages tooltip

* Styles and translation

* Navigation update

* Disable edit translations button when no reference count

* Invert colors

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
Co-authored-by: Adrien de Peretti <adrien.deperetti@gmail.com>
This commit is contained in:
Nicolas Gorga
2025-12-17 09:36:50 -03:00
committed by GitHub
parent c1a5390fc6
commit 3d1330ebb9
69 changed files with 3595 additions and 112 deletions

View File

@@ -4,6 +4,7 @@ import { PropsWithChildren } from "react"
type IconAvatarProps = PropsWithChildren<{
className?: string
size?: "small" | "large" | "xlarge"
variant?: "squared" | "rounded"
}>
/**
@@ -13,6 +14,7 @@ type IconAvatarProps = PropsWithChildren<{
*/
export const IconAvatar = ({
size = "small",
variant = "rounded",
children,
className,
}: IconAvatarProps) => {
@@ -20,6 +22,8 @@ export const IconAvatar = ({
<div
className={clx(
"shadow-borders-base flex size-7 items-center justify-center",
variant === "squared" && "rounded-md",
variant === "rounded" && "rounded-full",
"[&>div]:bg-ui-bg-field [&>div]:text-ui-fg-subtle [&>div]:flex [&>div]:size-6 [&>div]:items-center [&>div]:justify-center",
{
"size-7 rounded-md [&>div]:size-6 [&>div]:rounded-[4px]":

View File

@@ -20,16 +20,20 @@ export const DataGridCellContainer = ({
errors,
rowErrors,
outerComponent,
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
isMultiLine,
}: DataGridCellContainerProps &
DataGridErrorRenderProps<any> & { isMultiLine?: boolean }) => {
const error = get(errors, field)
const hasError = !!error
return (
<div className="group/container relative size-full">
<div className={clx("group/container relative h-full w-full")}>
<div
className={clx(
"bg-ui-bg-base group/cell relative flex size-full items-center gap-x-2 px-4 py-2.5 outline-none",
"bg-ui-bg-base group/cell relative flex h-full w-full gap-x-2 px-4 py-2.5 outline-none",
{
"items-center": !isMultiLine,
"items-start": isMultiLine,
"bg-ui-tag-red-bg text-ui-tag-red-text":
hasError && !isAnchor && !isSelected && !isDragSelected,
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
@@ -54,7 +58,12 @@ export const DataGridCellContainer = ({
)
}}
/>
<div className="relative z-[1] flex size-full items-center justify-center">
<div
className={clx("relative z-[1] flex h-full w-full", {
"items-center justify-center": !isMultiLine,
"items-start": isMultiLine,
})}
>
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
{children}
</RenderChildren>

View File

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

View File

@@ -9,24 +9,34 @@ type DataGridReadonlyCellProps<TData, TValue = any> = PropsWithChildren<
DataGridCellProps<TData, TValue>
> & {
color?: "muted" | "normal"
isMultiLine?: boolean
}
export const DataGridReadonlyCell = <TData, TValue = any>({
context,
color = "muted",
children,
isMultiLine = false,
}: DataGridReadonlyCellProps<TData, TValue>) => {
const { rowErrors } = useDataGridCellError({ context })
return (
<div
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none",
"txt-compact-small text-ui-fg-subtle flex w-full cursor-not-allowed justify-between overflow-hidden px-4 py-2.5 outline-none",
color === "muted" && "bg-ui-bg-subtle",
color === "normal" && "bg-ui-bg-base"
color === "normal" && "bg-ui-bg-base",
"h-full items-center"
)}
>
<div className="flex-1 truncate">{children}</div>
<div
className={clx("flex-1", {
truncate: !isMultiLine,
"whitespace-pre-wrap break-words": isMultiLine,
})}
>
{children}
</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
</div>
)

View File

@@ -15,7 +15,11 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual"
import {
VirtualItem,
Virtualizer,
useVirtualizer,
} from "@tanstack/react-virtual"
import React, {
CSSProperties,
ReactNode,
@@ -51,7 +55,7 @@ import { isCellMatch, isSpecialFocusKey } from "../utils"
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues,
TFieldValues extends FieldValues = FieldValues
> {
data?: TData[]
columns: ColumnDef<TData>[]
@@ -60,6 +64,25 @@ export interface DataGridRootProps<
onEditingChange?: (isEditing: boolean) => void
disableInteractions?: boolean
multiColumnSelection?: boolean
showColumnsDropdown?: boolean
/**
* Custom content to render in the header, positioned between the column visibility
* controls and the error/shortcuts section.
*/
headerContent?: ReactNode
/**
* Lazy loading props - when totalRowCount is provided, the grid enters lazy loading mode.
* In this mode, the virtualizer will size based on totalRowCount and trigger onFetchMore
* when the user scrolls near the end of loaded data.
*/
/** Total count of rows for scroll sizing (enables lazy loading mode when provided) */
totalRowCount?: number
/** Called when more data should be fetched */
onFetchMore?: () => void
/** Whether more data is currently being fetched */
isFetchingMore?: boolean
/** Whether there is more data to fetch */
hasNextPage?: boolean
}
const ROW_HEIGHT = 40
@@ -97,7 +120,7 @@ const getCommonPinningStyles = <TData,>(
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues,
TFieldValues extends FieldValues = FieldValues
>({
data = [],
columns,
@@ -106,7 +129,15 @@ export const DataGridRoot = <
onEditingChange,
disableInteractions,
multiColumnSelection = false,
showColumnsDropdown = true,
totalRowCount,
onFetchMore,
isFetchingMore,
hasNextPage,
headerContent,
}: DataGridRootProps<TData, TFieldValues>) => {
// TODO: remove once everything is lazy loaded
const isLazyMode = totalRowCount !== undefined
const containerRef = useRef<HTMLDivElement>(null)
const { redo, undo, execute } = useCommandHistory()
@@ -163,10 +194,18 @@ export const DataGridRoot = <
)
const visibleColumns = grid.getVisibleLeafColumns()
const effectiveRowCount = isLazyMode ? totalRowCount! : visibleRows.length
const rowVirtualizer = useVirtualizer({
count: visibleRows.length,
count: effectiveRowCount,
estimateSize: () => ROW_HEIGHT,
getScrollElement: () => containerRef.current,
// Measure actual row heights for dynamic sizing (disabled in Firefox due to measurement issues). Taken from Tanstack
measureElement:
typeof window !== "undefined" &&
navigator.userAgent.indexOf("Firefox") === -1
? (element) => element?.getBoundingClientRect().height
: undefined,
overscan: 5,
rangeExtractor: (range) => {
const toRender = new Set(
@@ -189,6 +228,76 @@ export const DataGridRoot = <
})
const virtualRows = rowVirtualizer.getVirtualItems()
/**
* Lazy loading scroll detection.
* When the user scrolls near the end of loaded data, trigger onFetchMore.
* We use refs to get latest values in the scroll handler without re-attaching.
*/
const lazyLoadingRefs = useRef({
onFetchMore,
hasNextPage,
isFetchingMore,
loadedRowCount: visibleRows.length,
})
// Keep refs updated
useEffect(() => {
lazyLoadingRefs.current = {
onFetchMore,
hasNextPage,
isFetchingMore,
loadedRowCount: visibleRows.length,
}
}, [onFetchMore, hasNextPage, isFetchingMore, visibleRows.length])
const hasData = visibleRows.length > 0
const handleScroll = useCallback(() => {
const { onFetchMore, hasNextPage, isFetchingMore, loadedRowCount } =
lazyLoadingRefs.current
if (!onFetchMore || !hasNextPage || isFetchingMore) {
return
}
const scrollElement = containerRef.current
const { scrollTop, clientHeight } = scrollElement!
const loadedHeight = loadedRowCount * ROW_HEIGHT
const viewportBottom = scrollTop + clientHeight
const fetchThreshold = loadedHeight - ROW_HEIGHT * 10
if (viewportBottom >= fetchThreshold) {
onFetchMore()
}
}, [lazyLoadingRefs, containerRef])
useEffect(() => {
if (!isLazyMode || !hasData) {
return
}
const container = containerRef.current
if (!container) {
return
}
const timeoutId = setTimeout(() => {
const scrollElement: HTMLElement | null = containerRef.current
if (!scrollElement) {
return
}
scrollElement.addEventListener("scroll", handleScroll)
}, 100)
return () => {
clearTimeout(timeoutId)
const scrollElement = containerRef.current
scrollElement?.removeEventListener("scroll", handleScroll)
}
}, [isLazyMode, hasData])
const columnVirtualizer = useVirtualizer({
count: visibleColumns.length,
estimateSize: (index) => visibleColumns[index].getSize(),
@@ -552,6 +661,7 @@ export const DataGridRoot = <
<DataGridContext.Provider value={values}>
<div className="bg-ui-bg-subtle flex size-full flex-col">
<DataGridHeader
showColumnsDropdown={showColumnsDropdown}
columnOptions={columnOptions}
isDisabled={isColumsDisabled}
onToggleColumn={handleToggleColumnVisibility}
@@ -560,6 +670,7 @@ export const DataGridRoot = <
onResetColumns={handleResetColumns}
isHighlighted={isHighlighted}
onHeaderInteractionChange={handleHeaderInteractionChange}
headerContent={headerContent}
/>
<div className="size-full overflow-hidden">
<div
@@ -650,6 +761,20 @@ export const DataGridRoot = <
>
{virtualRows.map((virtualRow) => {
const row = visibleRows[virtualRow.index] as Row<TData>
// In lazy mode, rows beyond loaded data show as skeleton
if (!row) {
return (
<DataGridRowSkeleton
key={`skeleton-${virtualRow.index}`}
virtualRow={virtualRow}
virtualColumns={virtualColumns}
virtualPaddingLeft={virtualPaddingLeft}
virtualPaddingRight={virtualPaddingRight}
/>
)
}
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
return (
@@ -658,6 +783,7 @@ export const DataGridRoot = <
row={row}
rowIndex={rowIndex}
virtualRow={virtualRow}
rowVirtualizer={rowVirtualizer}
flatColumns={flatColumns}
virtualColumns={virtualColumns}
anchor={anchor}
@@ -680,12 +806,14 @@ export const DataGridRoot = <
type DataGridHeaderProps = {
columnOptions: GridColumnOption[]
isDisabled: boolean
showColumnsDropdown: boolean
onToggleColumn: (index: number) => (value: boolean) => void
onResetColumns: () => void
isHighlighted: boolean
errorCount: number
onToggleErrorHighlighting: () => void
onHeaderInteractionChange: (isActive: boolean) => void
headerContent?: ReactNode
}
const DataGridHeader = ({
@@ -697,6 +825,8 @@ const DataGridHeader = ({
errorCount,
onToggleErrorHighlighting,
onHeaderInteractionChange,
showColumnsDropdown,
headerContent,
}: DataGridHeaderProps) => {
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [columnsOpen, setColumnsOpen] = useState(false)
@@ -717,58 +847,61 @@ const DataGridHeader = ({
}
return (
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
<div className="flex items-center gap-x-2">
<DropdownMenu
dir={direction}
open={columnsOpen}
onOpenChange={handleColumnsOpenChange}
>
<ConditionalTooltip
showTooltip={isDisabled}
content={t("dataGrid.columns.disabled")}
{showColumnsDropdown && (
<div className="flex items-center gap-x-2">
<DropdownMenu
dir={direction}
open={columnsOpen}
onOpenChange={handleColumnsOpenChange}
>
<DropdownMenu.Trigger asChild disabled={isDisabled}>
<Button size="small" variant="secondary">
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
{t("dataGrid.columns.view")}
</Button>
</DropdownMenu.Trigger>
</ConditionalTooltip>
<DropdownMenu.Content>
{columnOptions.map((column, index) => {
const { checked, disabled, id, name } = column
<ConditionalTooltip
showTooltip={isDisabled}
content={t("dataGrid.columns.disabled")}
>
<DropdownMenu.Trigger asChild disabled={isDisabled}>
<Button size="small" variant="secondary">
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
{t("dataGrid.columns.view")}
</Button>
</DropdownMenu.Trigger>
</ConditionalTooltip>
<DropdownMenu.Content>
{columnOptions.map((column, index) => {
const { checked, disabled, id, name } = column
if (disabled) {
return null
}
if (disabled) {
return null
}
return (
<DropdownMenu.CheckboxItem
key={id}
checked={checked}
onCheckedChange={onToggleColumn(index)}
onSelect={(e) => e.preventDefault()}
>
{name}
</DropdownMenu.CheckboxItem>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
{hasChanged && (
<Button
size="small"
variant="transparent"
type="button"
onClick={onResetColumns}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
data-id="reset-columns"
>
{t("dataGrid.columns.resetToDefault")}
</Button>
)}
</div>
<div className="flex items-center gap-x-2">
return (
<DropdownMenu.CheckboxItem
key={id}
checked={checked}
onCheckedChange={onToggleColumn(index)}
onSelect={(e) => e.preventDefault()}
>
{name}
</DropdownMenu.CheckboxItem>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
{hasChanged && (
<Button
size="small"
variant="transparent"
type="button"
onClick={onResetColumns}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
data-id="reset-columns"
>
{t("dataGrid.columns.resetToDefault")}
</Button>
)}
</div>
)}
{headerContent}
<div className="ml-auto flex items-center gap-x-2">
{errorCount > 0 && (
<Button
size="small"
@@ -832,11 +965,11 @@ const DataGridCell = <TData,>({
data-row-index={rowIndex}
data-column-index={columnIndex}
className={clx(
"relative flex items-center border-b border-r p-0 outline-none"
"relative flex items-stretch border-b border-r p-0 outline-none"
)}
tabIndex={-1}
>
<div className="relative h-full w-full">
<div className="relative w-full">
{flexRender(cell.column.columnDef.cell, {
...cell.getContext(),
columnIndex,
@@ -861,10 +994,11 @@ const DataGridCell = <TData,>({
type DataGridRowProps<TData> = {
row: Row<TData>
rowIndex: number
virtualRow: VirtualItem<Element>
virtualRow: VirtualItem
rowVirtualizer: Virtualizer<HTMLDivElement, Element>
virtualPaddingLeft?: number
virtualPaddingRight?: number
virtualColumns: VirtualItem<Element>[]
virtualColumns: VirtualItem[]
flatColumns: Column<TData, unknown>[]
anchor: DataGridCoordinates | null
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
@@ -875,6 +1009,7 @@ const DataGridRow = <TData,>({
row,
rowIndex,
virtualRow,
rowVirtualizer,
virtualPaddingLeft,
virtualPaddingRight,
virtualColumns,
@@ -889,10 +1024,12 @@ const DataGridRow = <TData,>({
<div
role="row"
aria-rowindex={virtualRow.index}
data-index={virtualRow.index}
ref={(node) => rowVirtualizer.measureElement(node)}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
className="bg-ui-bg-subtle txt-compact-small absolute flex min-h-10 w-full"
>
{virtualPaddingLeft ? (
<div
@@ -943,3 +1080,77 @@ const DataGridRow = <TData,>({
</div>
)
}
/**
* Skeleton row component for lazy loading.
* Displays placeholder cells while data is being fetched.
*/
type DataGridRowSkeletonProps = {
virtualRow: VirtualItem
virtualPaddingLeft?: number
virtualPaddingRight?: number
virtualColumns: VirtualItem[]
}
const DataGridRowSkeleton = ({
virtualRow,
virtualPaddingLeft,
virtualPaddingRight,
virtualColumns,
}: DataGridRowSkeletonProps) => {
return (
<div
role="row"
aria-rowindex={virtualRow.index}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
>
{virtualPaddingLeft ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.map((vc, index, array) => {
const previousVC = array[index - 1]
const elements: ReactNode[] = []
if (previousVC && vc.index !== previousVC.index + 1) {
elements.push(
<div
key={`padding-${previousVC.index}-${vc.index}`}
role="presentation"
style={{
display: "flex",
width: `${vc.start - previousVC.end}px`,
}}
/>
)
}
elements.push(
<div
key={`skeleton-cell-${vc.index}`}
role="gridcell"
style={{ width: vc.size }}
className="relative flex items-center border-b border-r p-0 outline-none"
>
<div className="flex h-full w-full items-center px-4">
<div className="bg-ui-bg-component h-4 w-3/4 animate-pulse rounded" />
</div>
</div>
)
return elements
})}
{virtualPaddingRight ? (
<div
role="presentation"
style={{ display: "flex", width: virtualPaddingRight }}
/>
) : null}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { clx } from "@medusajs/ui"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
@@ -43,29 +43,29 @@ const Inner = ({
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
const [localValue, setLocalValue] = useState(value)
const inputElRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
const combinedRefs = useCombinedRefs(inputRef, ref)
const combinedRefs = useCombinedRefs(inputRef, ref, inputElRef)
return (
<input
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none",
"focus:cursor-text"
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer bg-transparent outline-none",
"focus:cursor-text",
"items-center justify-center"
)}
autoComplete="off"
tabIndex={-1}
value={localValue}
value={localValue ?? ""}
onChange={(e) => setLocalValue(e.target.value)}
ref={combinedRefs}
onBlur={() => {
onBlur()
onInputBlur()
// We propagate the change to the field only when the input is blurred
onChange(localValue, value)
}}
{...input}

View File

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

View File

@@ -1,7 +1,9 @@
export { DataGridBooleanCell } from "./data-grid-boolean-cell"
export { DataGridCurrencyCell } from "./data-grid-currency-cell"
export { DataGridMultilineCell } from "./data-grid-multiline-cell"
export { DataGridNumberCell } from "./data-grid-number-cell"
export { DataGridReadonlyCell as DataGridReadOnlyCell } from "./data-grid-readonly-cell"
export { DataGridRoot, type DataGridRootProps } from "./data-grid-root"
export { DataGridSkeleton } from "./data-grid-skeleton"
export { DataGridTextCell } from "./data-grid-text-cell"
export { DataGridExpandableTextCell } from "./data-grid-textarea-modal-cell"

View File

@@ -3,11 +3,13 @@ import { FieldValues } from "react-hook-form"
import {
DataGridBooleanCell,
DataGridCurrencyCell,
DataGridMultilineCell,
DataGridNumberCell,
DataGridReadOnlyCell,
DataGridRoot,
DataGridSkeleton,
DataGridTextCell,
DataGridExpandableTextCell,
type DataGridRootProps,
} from "./components"
@@ -18,6 +20,11 @@ interface DataGridProps<TData, TFieldValues extends FieldValues = FieldValues>
const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
isLoading,
// Lazy loading props - passed through to DataGridRoot
totalRowCount,
onFetchMore,
isFetchingMore,
hasNextPage,
...props
}: DataGridProps<TData, TFieldValues>) => {
return isLoading ? (
@@ -28,13 +35,21 @@ const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
}
/>
) : (
<DataGridRoot {...props} />
<DataGridRoot
{...props}
totalRowCount={totalRowCount}
onFetchMore={onFetchMore}
isFetchingMore={isFetchingMore}
hasNextPage={hasNextPage}
/>
)
}
export const DataGrid = Object.assign(_DataGrid, {
BooleanCell: DataGridBooleanCell,
TextCell: DataGridTextCell,
MultilineCell: DataGridMultilineCell,
ExpandableTextCell: DataGridExpandableTextCell,
NumberCell: DataGridNumberCell,
CurrencyCell: DataGridCurrencyCell,
ReadonlyCell: DataGridReadOnlyCell,

View File

@@ -36,6 +36,18 @@ type DataGridHelperColumnsProps<TData, TFieldValues extends FieldValues> = {
* @default false
*/
disableHiding?: boolean
/**
* The size of the column in pixels.
*/
size?: number
/**
* The minimum size of the column in pixels.
*/
minSize?: number
/**
* The maximum size of the column in pixels.
*/
maxSize?: number
} & (
| {
field: FieldFunction<TData, TFieldValues>
@@ -59,12 +71,18 @@ export function createDataGridHelper<
disableHiding = false,
field,
type,
size,
minSize,
maxSize,
}: DataGridHelperColumnsProps<TData, TFieldValues>) =>
columnHelper.display({
id,
header,
cell,
enableHiding: !disableHiding,
size,
minSize,
maxSize,
meta: {
name,
field,

View File

@@ -128,6 +128,7 @@ export const useDataGridCell = <TData, TValue>({
case "number":
return numberCharacterRegex.test(key)
case "text":
case "multiline-text":
return textCharacterRegex.test(key)
default:
// KeyboardEvents should not be forwareded to other types of cells
@@ -180,6 +181,17 @@ export const useDataGridCell = <TData, TValue>({
)?.set
nativeInputValueSetter?.call(inputRef.current, e.key)
const event = new Event("input", { bubbles: true })
inputRef.current.dispatchEvent(event)
} else if (inputRef.current instanceof HTMLTextAreaElement) {
inputRef.current.value = ""
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value"
)?.set
nativeTextAreaValueSetter?.call(inputRef.current, e.key)
// Trigger input event to notify react-hook-form
const event = new Event("input", { bubbles: true })
inputRef.current.dispatchEvent(event)
@@ -202,7 +214,7 @@ export const useDataGridCell = <TData, TValue>({
useEffect(() => {
if (isAnchor && !containerRef.current?.contains(document.activeElement)) {
containerRef.current?.focus()
containerRef.current?.focus({ preventScroll: true })
}
}, [isAnchor])

View File

@@ -228,6 +228,7 @@ export function convertArrayToPrimitive(
case "boolean":
return values.map(convertToBoolean)
case "text":
case "multiline-text":
return values.map(covertToString)
default:
throw new Error(`Unsupported target type "${type}".`)

View File

@@ -211,7 +211,22 @@ export const useDataGridKeydownEvent = <
[rangeEnd, matrix, getSelectionValues, setSelectionValues, execute]
)
const handleSpaceKeyTextOrNumber = useCallback(
const handleSpaceKeyText = useCallback(
(anchor: DataGridCoordinates) => {
const field = matrix.getCellField(anchor)
const input = queryTool?.getInput(anchor)
if (!field || !input) {
return
}
createSnapshot(anchor)
input.focus()
},
[matrix, queryTool, createSnapshot]
)
const handleSpaceKeyNumber = useCallback(
(anchor: DataGridCoordinates) => {
const field = matrix.getCellField(anchor)
const input = queryTool?.getInput(anchor)
@@ -303,9 +318,12 @@ export const useDataGridKeydownEvent = <
case "togglable-number":
handleSpaceKeyTogglableNumber(anchor)
break
case "number":
case "text":
handleSpaceKeyTextOrNumber(anchor)
case "multiline-text":
handleSpaceKeyText(anchor)
break
case "number":
handleSpaceKeyNumber(anchor)
break
}
},
@@ -314,7 +332,8 @@ export const useDataGridKeydownEvent = <
isEditing,
matrix,
handleSpaceKeyBoolean,
handleSpaceKeyTextOrNumber,
handleSpaceKeyText,
handleSpaceKeyNumber,
handleSpaceKeyTogglableNumber,
]
)
@@ -384,6 +403,30 @@ export const useDataGridKeydownEvent = <
[handleMoveOnEnter, handleEditOnEnter, isEditing]
)
/**
* Handles the enter key for multiline-text cells.
*
* The behavior is as follows:
* - If Shift+Enter is pressed while editing, allow the newline (don't prevent default).
* - If Enter is pressed while editing (without Shift), move to the next cell.
* - If the cell is currently not being edited, start editing the cell.
*/
const handleEnterKeyMultilineText = useCallback(
(e: KeyboardEvent, anchor: DataGridCoordinates) => {
if (isEditing) {
if (e.shiftKey) {
return
}
handleMoveOnEnter(e, anchor)
return
}
handleEditOnEnter(anchor)
},
[handleMoveOnEnter, handleEditOnEnter, isEditing]
)
/**
* Handles the enter key for boolean cells.
*
@@ -432,11 +475,18 @@ export const useDataGridKeydownEvent = <
return
}
e.preventDefault()
const type = matrix.getCellType(anchor)
if (type === "multiline-text" && isEditing && e.shiftKey) {
return
}
e.preventDefault()
switch (type) {
case "multiline-text":
handleEnterKeyMultilineText(e, anchor)
break
case "togglable-number":
case "text":
case "number":
@@ -448,7 +498,14 @@ export const useDataGridKeydownEvent = <
}
}
},
[anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean]
[
anchor,
matrix,
isEditing,
handleEnterKeyTextOrNumber,
handleEnterKeyBoolean,
handleEnterKeyMultilineText,
]
)
const handleDeleteKeyTogglableNumber = useCallback(
@@ -526,6 +583,7 @@ export const useDataGridKeydownEvent = <
switch (type) {
case "text":
case "multiline-text":
case "number":
handleDeleteKeyTextOrNumber(anchor, rangeEnd)
break

View File

@@ -16,6 +16,7 @@ import {
export type DataGridColumnType =
| "text"
| "multiline-text"
| "number"
| "boolean"
| "togglable-number"

View File

@@ -9,6 +9,7 @@ import { useExtension } from "../../../providers/extension-provider"
import { INavItem, NavItem } from "../nav-item"
import { Shell } from "../shell"
import { UserMenu } from "../user-menu"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
export const SettingsLayout = () => {
return (
@@ -19,6 +20,7 @@ export const SettingsLayout = () => {
}
const useSettingRoutes = (): INavItem[] => {
const isTranslationsEnabled = useFeatureFlag("translation")
const { t } = useTranslation()
return useMemo(
@@ -63,8 +65,16 @@ const useSettingRoutes = (): INavItem[] => {
label: t("stockLocations.domain"),
to: "/settings/locations",
},
...(isTranslationsEnabled
? [
{
label: t("translations.domain"),
to: "/settings/translations",
},
]
: []),
],
[t]
[t, isTranslationsEnabled]
)
}

View File

@@ -8,7 +8,7 @@ import { RouteModalProvider } from "../route-modal-provider/route-provider"
import { StackedModalProvider } from "../stacked-modal-provider"
type RouteFocusModalProps = PropsWithChildren<{
prev?: string | Partial<Path>
prev?: string | Partial<Path> | number
}>
const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
@@ -16,7 +16,8 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
const [open, setOpen] = useState(false)
const [stackedModalOpen, onStackedModalOpen] = useState(false)
const to = useStateAwareTo(prev)
const to: string | Partial<Path> | number =
typeof prev === "number" ? prev : useStateAwareTo(prev)
/**
* Open the modal when the component mounts. This
@@ -34,7 +35,11 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
const handleOpenChange = (open: boolean) => {
if (!open) {
document.body.style.pointerEvents = "auto"
navigate(to, { replace: true })
if (typeof to === "number") {
navigate(to)
} else {
navigate(to, { replace: true })
}
return
}

View File

@@ -24,7 +24,12 @@ export const RouteModalForm = <TFieldValues extends FieldValues = any>({
} = form
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
const { isSubmitSuccessful } = nextLocation.state || {}
// Check both nextLocation and currentLocation state for successful submission
// This handles browser history navigation (-1) where we set state on current location
const { isSubmitSuccessful: nextIsSuccessful } = nextLocation.state || {}
const { isSubmitSuccessful: currentIsSuccessful } =
currentLocation.state || {}
const isSubmitSuccessful = nextIsSuccessful || currentIsSuccessful
if (isSubmitSuccessful) {
onClose?.(true)

View File

@@ -1,9 +1,9 @@
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
import { Path, useNavigate } from "react-router-dom"
import { Path, useLocation, useNavigate } from "react-router-dom"
import { RouteModalProviderContext } from "./route-modal-context"
type RouteModalProviderProps = PropsWithChildren<{
prev: string | Partial<Path>
prev: string | Partial<Path> | number
}>
export const RouteModalProvider = ({
@@ -11,15 +11,27 @@ export const RouteModalProvider = ({
children,
}: RouteModalProviderProps) => {
const navigate = useNavigate()
const location = useLocation()
const [closeOnEscape, setCloseOnEscape] = useState(true)
const handleSuccess = useCallback(
(path?: string) => {
const to = path || prev
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
if (typeof to === "number") {
// Replace current location with success state, then navigate back
navigate(location.pathname + location.search, {
replace: true,
state: { ...location.state, isSubmitSuccessful: true },
})
setTimeout(() => {
navigate(to)
}, 0)
} else {
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
}
},
[navigate, prev]
[navigate, prev, location]
)
const value = useMemo(