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:
@@ -4,6 +4,7 @@ import { PropsWithChildren } from "react"
|
||||
type IconAvatarProps = PropsWithChildren<{
|
||||
className?: string
|
||||
size?: "small" | "large" | "xlarge"
|
||||
variant?: "squared" | "rounded"
|
||||
}>
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,7 @@ type IconAvatarProps = PropsWithChildren<{
|
||||
*/
|
||||
export const IconAvatar = ({
|
||||
size = "small",
|
||||
variant = "rounded",
|
||||
children,
|
||||
className,
|
||||
}: IconAvatarProps) => {
|
||||
@@ -20,6 +22,8 @@ export const IconAvatar = ({
|
||||
<div
|
||||
className={clx(
|
||||
"shadow-borders-base flex size-7 items-center justify-center",
|
||||
variant === "squared" && "rounded-md",
|
||||
variant === "rounded" && "rounded-full",
|
||||
"[&>div]:bg-ui-bg-field [&>div]:text-ui-fg-subtle [&>div]:flex [&>div]:size-6 [&>div]:items-center [&>div]:justify-center",
|
||||
{
|
||||
"size-7 rounded-md [&>div]:size-6 [&>div]:rounded-[4px]":
|
||||
|
||||
@@ -20,16 +20,20 @@ export const DataGridCellContainer = ({
|
||||
errors,
|
||||
rowErrors,
|
||||
outerComponent,
|
||||
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
|
||||
isMultiLine,
|
||||
}: DataGridCellContainerProps &
|
||||
DataGridErrorRenderProps<any> & { isMultiLine?: boolean }) => {
|
||||
const error = get(errors, field)
|
||||
const hasError = !!error
|
||||
|
||||
return (
|
||||
<div className="group/container relative size-full">
|
||||
<div className={clx("group/container relative h-full w-full")}>
|
||||
<div
|
||||
className={clx(
|
||||
"bg-ui-bg-base group/cell relative flex size-full items-center gap-x-2 px-4 py-2.5 outline-none",
|
||||
"bg-ui-bg-base group/cell relative flex h-full w-full gap-x-2 px-4 py-2.5 outline-none",
|
||||
{
|
||||
"items-center": !isMultiLine,
|
||||
"items-start": isMultiLine,
|
||||
"bg-ui-tag-red-bg text-ui-tag-red-text":
|
||||
hasError && !isAnchor && !isSelected && !isDragSelected,
|
||||
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
|
||||
@@ -54,7 +58,12 @@ export const DataGridCellContainer = ({
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-[1] flex size-full items-center justify-center">
|
||||
<div
|
||||
className={clx("relative z-[1] flex h-full w-full", {
|
||||
"items-center justify-center": !isMultiLine,
|
||||
"items-start": isMultiLine,
|
||||
})}
|
||||
>
|
||||
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
|
||||
{children}
|
||||
</RenderChildren>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Controller, ControllerRenderProps } from "react-hook-form"
|
||||
|
||||
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
||||
import { useDataGridCell, useDataGridCellError } from "../hooks"
|
||||
import { DataGridCellProps, InputProps } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
export const DataGridMultilineCell = <TData, TValue = any>({
|
||||
context,
|
||||
}: DataGridCellProps<TData, TValue>) => {
|
||||
const { field, control, renderProps } = useDataGridCell({
|
||||
context,
|
||||
})
|
||||
const errorProps = useDataGridCellError({ context })
|
||||
|
||||
const { container, input } = renderProps
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<DataGridCellContainer {...container} {...errorProps} isMultiLine>
|
||||
<Inner field={field} inputProps={input} />
|
||||
</DataGridCellContainer>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Inner = ({
|
||||
field,
|
||||
inputProps,
|
||||
}: {
|
||||
field: ControllerRenderProps<any, string>
|
||||
inputProps: InputProps
|
||||
}) => {
|
||||
const { onChange: _, onBlur, ref, value, ...rest } = field
|
||||
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
|
||||
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
}, [value])
|
||||
|
||||
const combinedRefs = useCombinedRefs(inputRef, ref, textareaRef)
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
// Reset height to 0 to get accurate scrollHeight
|
||||
textarea.style.height = "0px"
|
||||
// Set the height to match content (minimum 24px for min visible height)
|
||||
const newHeight = Math.max(textarea.scrollHeight, 24)
|
||||
textarea.style.height = `${newHeight}px`
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Adjust height when value changes
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight()
|
||||
}, [localValue, adjustTextareaHeight])
|
||||
|
||||
useEffect(() => {
|
||||
// Immediate adjustment
|
||||
adjustTextareaHeight()
|
||||
// Delayed adjustment to handle any layout shifts
|
||||
const timeoutId = setTimeout(adjustTextareaHeight, 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [adjustTextareaHeight])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className={clx(
|
||||
"txt-compact-small text-ui-fg-subtle flex w-full cursor-pointer bg-transparent outline-none",
|
||||
"focus:cursor-text",
|
||||
"resize-none overflow-hidden py-2"
|
||||
)}
|
||||
autoComplete="off"
|
||||
tabIndex={-1}
|
||||
value={localValue ?? ""}
|
||||
onChange={(e) => {
|
||||
setLocalValue(e.target.value)
|
||||
adjustTextareaHeight()
|
||||
}}
|
||||
ref={combinedRefs}
|
||||
onBlur={() => {
|
||||
onBlur()
|
||||
onInputBlur()
|
||||
onChange(localValue, value)
|
||||
}}
|
||||
{...input}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,24 +9,34 @@ type DataGridReadonlyCellProps<TData, TValue = any> = PropsWithChildren<
|
||||
DataGridCellProps<TData, TValue>
|
||||
> & {
|
||||
color?: "muted" | "normal"
|
||||
isMultiLine?: boolean
|
||||
}
|
||||
|
||||
export const DataGridReadonlyCell = <TData, TValue = any>({
|
||||
context,
|
||||
color = "muted",
|
||||
children,
|
||||
isMultiLine = false,
|
||||
}: DataGridReadonlyCellProps<TData, TValue>) => {
|
||||
const { rowErrors } = useDataGridCellError({ context })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none",
|
||||
"txt-compact-small text-ui-fg-subtle flex w-full cursor-not-allowed justify-between overflow-hidden px-4 py-2.5 outline-none",
|
||||
color === "muted" && "bg-ui-bg-subtle",
|
||||
color === "normal" && "bg-ui-bg-base"
|
||||
color === "normal" && "bg-ui-bg-base",
|
||||
"h-full items-center"
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 truncate">{children}</div>
|
||||
<div
|
||||
className={clx("flex-1", {
|
||||
truncate: !isMultiLine,
|
||||
"whitespace-pre-wrap break-words": isMultiLine,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<DataGridRowErrorIndicator rowErrors={rowErrors} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual"
|
||||
import {
|
||||
VirtualItem,
|
||||
Virtualizer,
|
||||
useVirtualizer,
|
||||
} from "@tanstack/react-virtual"
|
||||
import React, {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
@@ -51,7 +55,7 @@ import { isCellMatch, isSpecialFocusKey } from "../utils"
|
||||
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
|
||||
export interface DataGridRootProps<
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
> {
|
||||
data?: TData[]
|
||||
columns: ColumnDef<TData>[]
|
||||
@@ -60,6 +64,25 @@ export interface DataGridRootProps<
|
||||
onEditingChange?: (isEditing: boolean) => void
|
||||
disableInteractions?: boolean
|
||||
multiColumnSelection?: boolean
|
||||
showColumnsDropdown?: boolean
|
||||
/**
|
||||
* Custom content to render in the header, positioned between the column visibility
|
||||
* controls and the error/shortcuts section.
|
||||
*/
|
||||
headerContent?: ReactNode
|
||||
/**
|
||||
* Lazy loading props - when totalRowCount is provided, the grid enters lazy loading mode.
|
||||
* In this mode, the virtualizer will size based on totalRowCount and trigger onFetchMore
|
||||
* when the user scrolls near the end of loaded data.
|
||||
*/
|
||||
/** Total count of rows for scroll sizing (enables lazy loading mode when provided) */
|
||||
totalRowCount?: number
|
||||
/** Called when more data should be fetched */
|
||||
onFetchMore?: () => void
|
||||
/** Whether more data is currently being fetched */
|
||||
isFetchingMore?: boolean
|
||||
/** Whether there is more data to fetch */
|
||||
hasNextPage?: boolean
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 40
|
||||
@@ -97,7 +120,7 @@ const getCommonPinningStyles = <TData,>(
|
||||
|
||||
export const DataGridRoot = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
>({
|
||||
data = [],
|
||||
columns,
|
||||
@@ -106,7 +129,15 @@ export const DataGridRoot = <
|
||||
onEditingChange,
|
||||
disableInteractions,
|
||||
multiColumnSelection = false,
|
||||
showColumnsDropdown = true,
|
||||
totalRowCount,
|
||||
onFetchMore,
|
||||
isFetchingMore,
|
||||
hasNextPage,
|
||||
headerContent,
|
||||
}: DataGridRootProps<TData, TFieldValues>) => {
|
||||
// TODO: remove once everything is lazy loaded
|
||||
const isLazyMode = totalRowCount !== undefined
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { redo, undo, execute } = useCommandHistory()
|
||||
@@ -163,10 +194,18 @@ export const DataGridRoot = <
|
||||
)
|
||||
const visibleColumns = grid.getVisibleLeafColumns()
|
||||
|
||||
const effectiveRowCount = isLazyMode ? totalRowCount! : visibleRows.length
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: visibleRows.length,
|
||||
count: effectiveRowCount,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
getScrollElement: () => containerRef.current,
|
||||
// Measure actual row heights for dynamic sizing (disabled in Firefox due to measurement issues). Taken from Tanstack
|
||||
measureElement:
|
||||
typeof window !== "undefined" &&
|
||||
navigator.userAgent.indexOf("Firefox") === -1
|
||||
? (element) => element?.getBoundingClientRect().height
|
||||
: undefined,
|
||||
overscan: 5,
|
||||
rangeExtractor: (range) => {
|
||||
const toRender = new Set(
|
||||
@@ -189,6 +228,76 @@ export const DataGridRoot = <
|
||||
})
|
||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||
|
||||
/**
|
||||
* Lazy loading scroll detection.
|
||||
* When the user scrolls near the end of loaded data, trigger onFetchMore.
|
||||
* We use refs to get latest values in the scroll handler without re-attaching.
|
||||
*/
|
||||
const lazyLoadingRefs = useRef({
|
||||
onFetchMore,
|
||||
hasNextPage,
|
||||
isFetchingMore,
|
||||
loadedRowCount: visibleRows.length,
|
||||
})
|
||||
|
||||
// Keep refs updated
|
||||
useEffect(() => {
|
||||
lazyLoadingRefs.current = {
|
||||
onFetchMore,
|
||||
hasNextPage,
|
||||
isFetchingMore,
|
||||
loadedRowCount: visibleRows.length,
|
||||
}
|
||||
}, [onFetchMore, hasNextPage, isFetchingMore, visibleRows.length])
|
||||
|
||||
const hasData = visibleRows.length > 0
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const { onFetchMore, hasNextPage, isFetchingMore, loadedRowCount } =
|
||||
lazyLoadingRefs.current
|
||||
|
||||
if (!onFetchMore || !hasNextPage || isFetchingMore) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollElement = containerRef.current
|
||||
|
||||
const { scrollTop, clientHeight } = scrollElement!
|
||||
const loadedHeight = loadedRowCount * ROW_HEIGHT
|
||||
const viewportBottom = scrollTop + clientHeight
|
||||
const fetchThreshold = loadedHeight - ROW_HEIGHT * 10
|
||||
|
||||
if (viewportBottom >= fetchThreshold) {
|
||||
onFetchMore()
|
||||
}
|
||||
}, [lazyLoadingRefs, containerRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLazyMode || !hasData) {
|
||||
return
|
||||
}
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const scrollElement: HTMLElement | null = containerRef.current
|
||||
if (!scrollElement) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollElement.addEventListener("scroll", handleScroll)
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
const scrollElement = containerRef.current
|
||||
scrollElement?.removeEventListener("scroll", handleScroll)
|
||||
}
|
||||
}, [isLazyMode, hasData])
|
||||
|
||||
const columnVirtualizer = useVirtualizer({
|
||||
count: visibleColumns.length,
|
||||
estimateSize: (index) => visibleColumns[index].getSize(),
|
||||
@@ -552,6 +661,7 @@ export const DataGridRoot = <
|
||||
<DataGridContext.Provider value={values}>
|
||||
<div className="bg-ui-bg-subtle flex size-full flex-col">
|
||||
<DataGridHeader
|
||||
showColumnsDropdown={showColumnsDropdown}
|
||||
columnOptions={columnOptions}
|
||||
isDisabled={isColumsDisabled}
|
||||
onToggleColumn={handleToggleColumnVisibility}
|
||||
@@ -560,6 +670,7 @@ export const DataGridRoot = <
|
||||
onResetColumns={handleResetColumns}
|
||||
isHighlighted={isHighlighted}
|
||||
onHeaderInteractionChange={handleHeaderInteractionChange}
|
||||
headerContent={headerContent}
|
||||
/>
|
||||
<div className="size-full overflow-hidden">
|
||||
<div
|
||||
@@ -650,6 +761,20 @@ export const DataGridRoot = <
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = visibleRows[virtualRow.index] as Row<TData>
|
||||
|
||||
// In lazy mode, rows beyond loaded data show as skeleton
|
||||
if (!row) {
|
||||
return (
|
||||
<DataGridRowSkeleton
|
||||
key={`skeleton-${virtualRow.index}`}
|
||||
virtualRow={virtualRow}
|
||||
virtualColumns={virtualColumns}
|
||||
virtualPaddingLeft={virtualPaddingLeft}
|
||||
virtualPaddingRight={virtualPaddingRight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
|
||||
|
||||
return (
|
||||
@@ -658,6 +783,7 @@ export const DataGridRoot = <
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
virtualRow={virtualRow}
|
||||
rowVirtualizer={rowVirtualizer}
|
||||
flatColumns={flatColumns}
|
||||
virtualColumns={virtualColumns}
|
||||
anchor={anchor}
|
||||
@@ -680,12 +806,14 @@ export const DataGridRoot = <
|
||||
type DataGridHeaderProps = {
|
||||
columnOptions: GridColumnOption[]
|
||||
isDisabled: boolean
|
||||
showColumnsDropdown: boolean
|
||||
onToggleColumn: (index: number) => (value: boolean) => void
|
||||
onResetColumns: () => void
|
||||
isHighlighted: boolean
|
||||
errorCount: number
|
||||
onToggleErrorHighlighting: () => void
|
||||
onHeaderInteractionChange: (isActive: boolean) => void
|
||||
headerContent?: ReactNode
|
||||
}
|
||||
|
||||
const DataGridHeader = ({
|
||||
@@ -697,6 +825,8 @@ const DataGridHeader = ({
|
||||
errorCount,
|
||||
onToggleErrorHighlighting,
|
||||
onHeaderInteractionChange,
|
||||
showColumnsDropdown,
|
||||
headerContent,
|
||||
}: DataGridHeaderProps) => {
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||
const [columnsOpen, setColumnsOpen] = useState(false)
|
||||
@@ -717,58 +847,61 @@ const DataGridHeader = ({
|
||||
}
|
||||
return (
|
||||
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<DropdownMenu
|
||||
dir={direction}
|
||||
open={columnsOpen}
|
||||
onOpenChange={handleColumnsOpenChange}
|
||||
>
|
||||
<ConditionalTooltip
|
||||
showTooltip={isDisabled}
|
||||
content={t("dataGrid.columns.disabled")}
|
||||
{showColumnsDropdown && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<DropdownMenu
|
||||
dir={direction}
|
||||
open={columnsOpen}
|
||||
onOpenChange={handleColumnsOpenChange}
|
||||
>
|
||||
<DropdownMenu.Trigger asChild disabled={isDisabled}>
|
||||
<Button size="small" variant="secondary">
|
||||
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
|
||||
{t("dataGrid.columns.view")}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
</ConditionalTooltip>
|
||||
<DropdownMenu.Content>
|
||||
{columnOptions.map((column, index) => {
|
||||
const { checked, disabled, id, name } = column
|
||||
<ConditionalTooltip
|
||||
showTooltip={isDisabled}
|
||||
content={t("dataGrid.columns.disabled")}
|
||||
>
|
||||
<DropdownMenu.Trigger asChild disabled={isDisabled}>
|
||||
<Button size="small" variant="secondary">
|
||||
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
|
||||
{t("dataGrid.columns.view")}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
</ConditionalTooltip>
|
||||
<DropdownMenu.Content>
|
||||
{columnOptions.map((column, index) => {
|
||||
const { checked, disabled, id, name } = column
|
||||
|
||||
if (disabled) {
|
||||
return null
|
||||
}
|
||||
if (disabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onToggleColumn(index)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{name}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
{hasChanged && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
onClick={onResetColumns}
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
data-id="reset-columns"
|
||||
>
|
||||
{t("dataGrid.columns.resetToDefault")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
return (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onToggleColumn(index)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{name}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
{hasChanged && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
onClick={onResetColumns}
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
data-id="reset-columns"
|
||||
>
|
||||
{t("dataGrid.columns.resetToDefault")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{headerContent}
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
{errorCount > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -832,11 +965,11 @@ const DataGridCell = <TData,>({
|
||||
data-row-index={rowIndex}
|
||||
data-column-index={columnIndex}
|
||||
className={clx(
|
||||
"relative flex items-center border-b border-r p-0 outline-none"
|
||||
"relative flex items-stretch border-b border-r p-0 outline-none"
|
||||
)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
<div className="relative w-full">
|
||||
{flexRender(cell.column.columnDef.cell, {
|
||||
...cell.getContext(),
|
||||
columnIndex,
|
||||
@@ -861,10 +994,11 @@ const DataGridCell = <TData,>({
|
||||
type DataGridRowProps<TData> = {
|
||||
row: Row<TData>
|
||||
rowIndex: number
|
||||
virtualRow: VirtualItem<Element>
|
||||
virtualRow: VirtualItem
|
||||
rowVirtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
virtualPaddingLeft?: number
|
||||
virtualPaddingRight?: number
|
||||
virtualColumns: VirtualItem<Element>[]
|
||||
virtualColumns: VirtualItem[]
|
||||
flatColumns: Column<TData, unknown>[]
|
||||
anchor: DataGridCoordinates | null
|
||||
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
|
||||
@@ -875,6 +1009,7 @@ const DataGridRow = <TData,>({
|
||||
row,
|
||||
rowIndex,
|
||||
virtualRow,
|
||||
rowVirtualizer,
|
||||
virtualPaddingLeft,
|
||||
virtualPaddingRight,
|
||||
virtualColumns,
|
||||
@@ -889,10 +1024,12 @@ const DataGridRow = <TData,>({
|
||||
<div
|
||||
role="row"
|
||||
aria-rowindex={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={(node) => rowVirtualizer.measureElement(node)}
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
|
||||
className="bg-ui-bg-subtle txt-compact-small absolute flex min-h-10 w-full"
|
||||
>
|
||||
{virtualPaddingLeft ? (
|
||||
<div
|
||||
@@ -943,3 +1080,77 @@ const DataGridRow = <TData,>({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton row component for lazy loading.
|
||||
* Displays placeholder cells while data is being fetched.
|
||||
*/
|
||||
type DataGridRowSkeletonProps = {
|
||||
virtualRow: VirtualItem
|
||||
virtualPaddingLeft?: number
|
||||
virtualPaddingRight?: number
|
||||
virtualColumns: VirtualItem[]
|
||||
}
|
||||
|
||||
const DataGridRowSkeleton = ({
|
||||
virtualRow,
|
||||
virtualPaddingLeft,
|
||||
virtualPaddingRight,
|
||||
virtualColumns,
|
||||
}: DataGridRowSkeletonProps) => {
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
aria-rowindex={virtualRow.index}
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
|
||||
>
|
||||
{virtualPaddingLeft ? (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ display: "flex", width: virtualPaddingLeft }}
|
||||
/>
|
||||
) : null}
|
||||
{virtualColumns.map((vc, index, array) => {
|
||||
const previousVC = array[index - 1]
|
||||
const elements: ReactNode[] = []
|
||||
|
||||
if (previousVC && vc.index !== previousVC.index + 1) {
|
||||
elements.push(
|
||||
<div
|
||||
key={`padding-${previousVC.index}-${vc.index}`}
|
||||
role="presentation"
|
||||
style={{
|
||||
display: "flex",
|
||||
width: `${vc.start - previousVC.end}px`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<div
|
||||
key={`skeleton-cell-${vc.index}`}
|
||||
role="gridcell"
|
||||
style={{ width: vc.size }}
|
||||
className="relative flex items-center border-b border-r p-0 outline-none"
|
||||
>
|
||||
<div className="flex h-full w-full items-center px-4">
|
||||
<div className="bg-ui-bg-component h-4 w-3/4 animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return elements
|
||||
})}
|
||||
{virtualPaddingRight ? (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ display: "flex", width: virtualPaddingRight }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Controller, ControllerRenderProps } from "react-hook-form"
|
||||
|
||||
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
||||
@@ -43,29 +43,29 @@ const Inner = ({
|
||||
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
|
||||
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const inputElRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
}, [value])
|
||||
|
||||
const combinedRefs = useCombinedRefs(inputRef, ref)
|
||||
const combinedRefs = useCombinedRefs(inputRef, ref, inputElRef)
|
||||
|
||||
return (
|
||||
<input
|
||||
className={clx(
|
||||
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none",
|
||||
"focus:cursor-text"
|
||||
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer bg-transparent outline-none",
|
||||
"focus:cursor-text",
|
||||
"items-center justify-center"
|
||||
)}
|
||||
autoComplete="off"
|
||||
tabIndex={-1}
|
||||
value={localValue}
|
||||
value={localValue ?? ""}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
ref={combinedRefs}
|
||||
onBlur={() => {
|
||||
onBlur()
|
||||
onInputBlur()
|
||||
|
||||
// We propagate the change to the field only when the input is blurred
|
||||
onChange(localValue, value)
|
||||
}}
|
||||
{...input}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { clx, Textarea } from "@medusajs/ui"
|
||||
import { Popover as RadixPopover } from "radix-ui"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Controller, ControllerRenderProps } from "react-hook-form"
|
||||
|
||||
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
||||
import { useDataGridCell, useDataGridCellError } from "../hooks"
|
||||
import { useDataGridContext } from "../context"
|
||||
import { DataGridCellProps, InputProps, DataGridCellContext } from "../types"
|
||||
import { DataGridCellContainer } from "./data-grid-cell-container"
|
||||
|
||||
type DataGridExpandableTextCellProps<TData, TValue = any> = DataGridCellProps<
|
||||
TData,
|
||||
TValue
|
||||
> & {
|
||||
fieldLabel?: string
|
||||
}
|
||||
|
||||
export const DataGridExpandableTextCell = <TData, TValue = any>({
|
||||
context,
|
||||
fieldLabel,
|
||||
}: DataGridExpandableTextCellProps<TData, TValue>) => {
|
||||
const { field, control, renderProps } = useDataGridCell({
|
||||
context,
|
||||
})
|
||||
const errorProps = useDataGridCellError({ context })
|
||||
|
||||
const { container, input } = renderProps
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Inner
|
||||
field={field}
|
||||
inputProps={input}
|
||||
fieldLabel={fieldLabel}
|
||||
container={container}
|
||||
errorProps={errorProps}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Inner = ({
|
||||
field,
|
||||
inputProps,
|
||||
fieldLabel: _fieldLabel,
|
||||
container,
|
||||
errorProps,
|
||||
}: {
|
||||
field: ControllerRenderProps<any, string>
|
||||
inputProps: InputProps
|
||||
fieldLabel?: string
|
||||
container: any
|
||||
errorProps: any
|
||||
}) => {
|
||||
const { onChange: _, onBlur, ref, value, ...rest } = field
|
||||
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
|
||||
const { setSingleRange, anchor } = useDataGridContext()
|
||||
const { row, col } = anchor || { row: 0, col: 0 }
|
||||
|
||||
const [localValue, setLocalValue] = useState(value || "")
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
const [popoverValue, setPopoverValue] = useState(value || "")
|
||||
const popoverContentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value || "")
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPopoverOpen) {
|
||||
setPopoverValue(value || "")
|
||||
}
|
||||
}, [isPopoverOpen, value])
|
||||
|
||||
// Prevent DataGrid keyboard handlers from intercepting keys when popover is open
|
||||
useEffect(() => {
|
||||
if (!isPopoverOpen || !popoverContentRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleKeyDownCapture = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const isTextarea = target.tagName === "TEXTAREA"
|
||||
const isInPopover =
|
||||
popoverContentRef.current && popoverContentRef.current.contains(target)
|
||||
|
||||
if (isTextarea || isInPopover) {
|
||||
const dataGridKeys = [
|
||||
"Enter",
|
||||
"Delete",
|
||||
"Backspace",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Tab",
|
||||
" ",
|
||||
]
|
||||
|
||||
// Stop the keys from reaching DataGrid, so the textarea can handle them
|
||||
if (dataGridKeys.includes(e.key) && e.key !== "Escape") {
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDownCapture, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDownCapture, true)
|
||||
}
|
||||
}, [isPopoverOpen])
|
||||
|
||||
const combinedRefs = useCombinedRefs(inputRef, ref)
|
||||
|
||||
const handleOverlayMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.detail === 2) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSingleRange({ row, col })
|
||||
setIsPopoverOpen(true)
|
||||
return
|
||||
}
|
||||
// For single clicks, use the normal handler which sets anchor and focuses container
|
||||
container.overlayProps.onMouseDown?.(e)
|
||||
},
|
||||
[container.overlayProps, setSingleRange, row, col]
|
||||
)
|
||||
|
||||
const customContainer = {
|
||||
...container,
|
||||
overlayProps: {
|
||||
...container.overlayProps,
|
||||
onMouseDown: handleOverlayMouseDown,
|
||||
},
|
||||
}
|
||||
|
||||
const handlePopoverSave = () => {
|
||||
onChange(popoverValue, value)
|
||||
setLocalValue(popoverValue)
|
||||
setIsPopoverOpen(false)
|
||||
onBlur()
|
||||
onInputBlur()
|
||||
}
|
||||
|
||||
const handlePopoverKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key !== "Escape") {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const displayValue = localValue || ""
|
||||
const truncatedValue =
|
||||
displayValue.length > 50
|
||||
? `${displayValue.substring(0, 50)}...`
|
||||
: displayValue
|
||||
|
||||
return (
|
||||
<RadixPopover.Root
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handlePopoverSave()
|
||||
} else {
|
||||
setIsPopoverOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DataGridCellContainer {...customContainer} {...errorProps}>
|
||||
<RadixPopover.Anchor asChild>
|
||||
<div
|
||||
className={clx(
|
||||
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none",
|
||||
"focus:cursor-text"
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate text-center">
|
||||
{truncatedValue}
|
||||
</span>
|
||||
</div>
|
||||
</RadixPopover.Anchor>
|
||||
<input
|
||||
className="sr-only"
|
||||
autoComplete="off"
|
||||
tabIndex={-1}
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
ref={combinedRefs}
|
||||
onBlur={() => {
|
||||
onBlur()
|
||||
onInputBlur()
|
||||
onChange(localValue, value)
|
||||
}}
|
||||
{...input}
|
||||
{...rest}
|
||||
/>
|
||||
</DataGridCellContainer>
|
||||
<RadixPopover.Portal>
|
||||
<RadixPopover.Content
|
||||
className={clx(
|
||||
"bg-ui-bg-base shadow-elevation-flyout flex max-h-[80vh] w-[600px] overflow-hidden p-0 outline-none"
|
||||
)}
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={-29}
|
||||
alignOffset={-16}
|
||||
collisionPadding={24}
|
||||
onEscapeKeyDown={handlePopoverSave}
|
||||
onKeyDown={handlePopoverKeyDown}
|
||||
>
|
||||
<div ref={popoverContentRef} className="h-full w-full">
|
||||
<Textarea
|
||||
value={popoverValue}
|
||||
onChange={(e) => setPopoverValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className="!bg-ui-bg-base h-full min-h-[300px] w-full resize-none border-0 p-4 !shadow-none focus-visible:!shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</RadixPopover.Content>
|
||||
</RadixPopover.Portal>
|
||||
</RadixPopover.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
export { DataGridBooleanCell } from "./data-grid-boolean-cell"
|
||||
export { DataGridCurrencyCell } from "./data-grid-currency-cell"
|
||||
export { DataGridMultilineCell } from "./data-grid-multiline-cell"
|
||||
export { DataGridNumberCell } from "./data-grid-number-cell"
|
||||
export { DataGridReadonlyCell as DataGridReadOnlyCell } from "./data-grid-readonly-cell"
|
||||
export { DataGridRoot, type DataGridRootProps } from "./data-grid-root"
|
||||
export { DataGridSkeleton } from "./data-grid-skeleton"
|
||||
export { DataGridTextCell } from "./data-grid-text-cell"
|
||||
export { DataGridExpandableTextCell } from "./data-grid-textarea-modal-cell"
|
||||
|
||||
@@ -3,11 +3,13 @@ import { FieldValues } from "react-hook-form"
|
||||
import {
|
||||
DataGridBooleanCell,
|
||||
DataGridCurrencyCell,
|
||||
DataGridMultilineCell,
|
||||
DataGridNumberCell,
|
||||
DataGridReadOnlyCell,
|
||||
DataGridRoot,
|
||||
DataGridSkeleton,
|
||||
DataGridTextCell,
|
||||
DataGridExpandableTextCell,
|
||||
type DataGridRootProps,
|
||||
} from "./components"
|
||||
|
||||
@@ -18,6 +20,11 @@ interface DataGridProps<TData, TFieldValues extends FieldValues = FieldValues>
|
||||
|
||||
const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
|
||||
isLoading,
|
||||
// Lazy loading props - passed through to DataGridRoot
|
||||
totalRowCount,
|
||||
onFetchMore,
|
||||
isFetchingMore,
|
||||
hasNextPage,
|
||||
...props
|
||||
}: DataGridProps<TData, TFieldValues>) => {
|
||||
return isLoading ? (
|
||||
@@ -28,13 +35,21 @@ const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DataGridRoot {...props} />
|
||||
<DataGridRoot
|
||||
{...props}
|
||||
totalRowCount={totalRowCount}
|
||||
onFetchMore={onFetchMore}
|
||||
isFetchingMore={isFetchingMore}
|
||||
hasNextPage={hasNextPage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const DataGrid = Object.assign(_DataGrid, {
|
||||
BooleanCell: DataGridBooleanCell,
|
||||
TextCell: DataGridTextCell,
|
||||
MultilineCell: DataGridMultilineCell,
|
||||
ExpandableTextCell: DataGridExpandableTextCell,
|
||||
NumberCell: DataGridNumberCell,
|
||||
CurrencyCell: DataGridCurrencyCell,
|
||||
ReadonlyCell: DataGridReadOnlyCell,
|
||||
|
||||
@@ -36,6 +36,18 @@ type DataGridHelperColumnsProps<TData, TFieldValues extends FieldValues> = {
|
||||
* @default false
|
||||
*/
|
||||
disableHiding?: boolean
|
||||
/**
|
||||
* The size of the column in pixels.
|
||||
*/
|
||||
size?: number
|
||||
/**
|
||||
* The minimum size of the column in pixels.
|
||||
*/
|
||||
minSize?: number
|
||||
/**
|
||||
* The maximum size of the column in pixels.
|
||||
*/
|
||||
maxSize?: number
|
||||
} & (
|
||||
| {
|
||||
field: FieldFunction<TData, TFieldValues>
|
||||
@@ -59,12 +71,18 @@ export function createDataGridHelper<
|
||||
disableHiding = false,
|
||||
field,
|
||||
type,
|
||||
size,
|
||||
minSize,
|
||||
maxSize,
|
||||
}: DataGridHelperColumnsProps<TData, TFieldValues>) =>
|
||||
columnHelper.display({
|
||||
id,
|
||||
header,
|
||||
cell,
|
||||
enableHiding: !disableHiding,
|
||||
size,
|
||||
minSize,
|
||||
maxSize,
|
||||
meta: {
|
||||
name,
|
||||
field,
|
||||
|
||||
@@ -128,6 +128,7 @@ export const useDataGridCell = <TData, TValue>({
|
||||
case "number":
|
||||
return numberCharacterRegex.test(key)
|
||||
case "text":
|
||||
case "multiline-text":
|
||||
return textCharacterRegex.test(key)
|
||||
default:
|
||||
// KeyboardEvents should not be forwareded to other types of cells
|
||||
@@ -180,6 +181,17 @@ export const useDataGridCell = <TData, TValue>({
|
||||
)?.set
|
||||
nativeInputValueSetter?.call(inputRef.current, e.key)
|
||||
|
||||
const event = new Event("input", { bubbles: true })
|
||||
inputRef.current.dispatchEvent(event)
|
||||
} else if (inputRef.current instanceof HTMLTextAreaElement) {
|
||||
inputRef.current.value = ""
|
||||
|
||||
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value"
|
||||
)?.set
|
||||
nativeTextAreaValueSetter?.call(inputRef.current, e.key)
|
||||
|
||||
// Trigger input event to notify react-hook-form
|
||||
const event = new Event("input", { bubbles: true })
|
||||
inputRef.current.dispatchEvent(event)
|
||||
@@ -202,7 +214,7 @@ export const useDataGridCell = <TData, TValue>({
|
||||
|
||||
useEffect(() => {
|
||||
if (isAnchor && !containerRef.current?.contains(document.activeElement)) {
|
||||
containerRef.current?.focus()
|
||||
containerRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
}, [isAnchor])
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ export function convertArrayToPrimitive(
|
||||
case "boolean":
|
||||
return values.map(convertToBoolean)
|
||||
case "text":
|
||||
case "multiline-text":
|
||||
return values.map(covertToString)
|
||||
default:
|
||||
throw new Error(`Unsupported target type "${type}".`)
|
||||
|
||||
@@ -211,7 +211,22 @@ export const useDataGridKeydownEvent = <
|
||||
[rangeEnd, matrix, getSelectionValues, setSelectionValues, execute]
|
||||
)
|
||||
|
||||
const handleSpaceKeyTextOrNumber = useCallback(
|
||||
const handleSpaceKeyText = useCallback(
|
||||
(anchor: DataGridCoordinates) => {
|
||||
const field = matrix.getCellField(anchor)
|
||||
const input = queryTool?.getInput(anchor)
|
||||
|
||||
if (!field || !input) {
|
||||
return
|
||||
}
|
||||
|
||||
createSnapshot(anchor)
|
||||
input.focus()
|
||||
},
|
||||
[matrix, queryTool, createSnapshot]
|
||||
)
|
||||
|
||||
const handleSpaceKeyNumber = useCallback(
|
||||
(anchor: DataGridCoordinates) => {
|
||||
const field = matrix.getCellField(anchor)
|
||||
const input = queryTool?.getInput(anchor)
|
||||
@@ -303,9 +318,12 @@ export const useDataGridKeydownEvent = <
|
||||
case "togglable-number":
|
||||
handleSpaceKeyTogglableNumber(anchor)
|
||||
break
|
||||
case "number":
|
||||
case "text":
|
||||
handleSpaceKeyTextOrNumber(anchor)
|
||||
case "multiline-text":
|
||||
handleSpaceKeyText(anchor)
|
||||
break
|
||||
case "number":
|
||||
handleSpaceKeyNumber(anchor)
|
||||
break
|
||||
}
|
||||
},
|
||||
@@ -314,7 +332,8 @@ export const useDataGridKeydownEvent = <
|
||||
isEditing,
|
||||
matrix,
|
||||
handleSpaceKeyBoolean,
|
||||
handleSpaceKeyTextOrNumber,
|
||||
handleSpaceKeyText,
|
||||
handleSpaceKeyNumber,
|
||||
handleSpaceKeyTogglableNumber,
|
||||
]
|
||||
)
|
||||
@@ -384,6 +403,30 @@ export const useDataGridKeydownEvent = <
|
||||
[handleMoveOnEnter, handleEditOnEnter, isEditing]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles the enter key for multiline-text cells.
|
||||
*
|
||||
* The behavior is as follows:
|
||||
* - If Shift+Enter is pressed while editing, allow the newline (don't prevent default).
|
||||
* - If Enter is pressed while editing (without Shift), move to the next cell.
|
||||
* - If the cell is currently not being edited, start editing the cell.
|
||||
*/
|
||||
const handleEnterKeyMultilineText = useCallback(
|
||||
(e: KeyboardEvent, anchor: DataGridCoordinates) => {
|
||||
if (isEditing) {
|
||||
if (e.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
handleMoveOnEnter(e, anchor)
|
||||
return
|
||||
}
|
||||
|
||||
handleEditOnEnter(anchor)
|
||||
},
|
||||
[handleMoveOnEnter, handleEditOnEnter, isEditing]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles the enter key for boolean cells.
|
||||
*
|
||||
@@ -432,11 +475,18 @@ export const useDataGridKeydownEvent = <
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const type = matrix.getCellType(anchor)
|
||||
|
||||
if (type === "multiline-text" && isEditing && e.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
switch (type) {
|
||||
case "multiline-text":
|
||||
handleEnterKeyMultilineText(e, anchor)
|
||||
break
|
||||
case "togglable-number":
|
||||
case "text":
|
||||
case "number":
|
||||
@@ -448,7 +498,14 @@ export const useDataGridKeydownEvent = <
|
||||
}
|
||||
}
|
||||
},
|
||||
[anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean]
|
||||
[
|
||||
anchor,
|
||||
matrix,
|
||||
isEditing,
|
||||
handleEnterKeyTextOrNumber,
|
||||
handleEnterKeyBoolean,
|
||||
handleEnterKeyMultilineText,
|
||||
]
|
||||
)
|
||||
|
||||
const handleDeleteKeyTogglableNumber = useCallback(
|
||||
@@ -526,6 +583,7 @@ export const useDataGridKeydownEvent = <
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
case "multiline-text":
|
||||
case "number":
|
||||
handleDeleteKeyTextOrNumber(anchor, rangeEnd)
|
||||
break
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
export type DataGridColumnType =
|
||||
| "text"
|
||||
| "multiline-text"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "togglable-number"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useExtension } from "../../../providers/extension-provider"
|
||||
import { INavItem, NavItem } from "../nav-item"
|
||||
import { Shell } from "../shell"
|
||||
import { UserMenu } from "../user-menu"
|
||||
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||
|
||||
export const SettingsLayout = () => {
|
||||
return (
|
||||
@@ -19,6 +20,7 @@ export const SettingsLayout = () => {
|
||||
}
|
||||
|
||||
const useSettingRoutes = (): INavItem[] => {
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
@@ -63,8 +65,16 @@ const useSettingRoutes = (): INavItem[] => {
|
||||
label: t("stockLocations.domain"),
|
||||
to: "/settings/locations",
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
label: t("translations.domain"),
|
||||
to: "/settings/translations",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[t]
|
||||
[t, isTranslationsEnabled]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RouteModalProvider } from "../route-modal-provider/route-provider"
|
||||
import { StackedModalProvider } from "../stacked-modal-provider"
|
||||
|
||||
type RouteFocusModalProps = PropsWithChildren<{
|
||||
prev?: string | Partial<Path>
|
||||
prev?: string | Partial<Path> | number
|
||||
}>
|
||||
|
||||
const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
|
||||
@@ -16,7 +16,8 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [stackedModalOpen, onStackedModalOpen] = useState(false)
|
||||
|
||||
const to = useStateAwareTo(prev)
|
||||
const to: string | Partial<Path> | number =
|
||||
typeof prev === "number" ? prev : useStateAwareTo(prev)
|
||||
|
||||
/**
|
||||
* Open the modal when the component mounts. This
|
||||
@@ -34,7 +35,11 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
document.body.style.pointerEvents = "auto"
|
||||
navigate(to, { replace: true })
|
||||
if (typeof to === "number") {
|
||||
navigate(to)
|
||||
} else {
|
||||
navigate(to, { replace: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,12 @@ export const RouteModalForm = <TFieldValues extends FieldValues = any>({
|
||||
} = form
|
||||
|
||||
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
|
||||
const { isSubmitSuccessful } = nextLocation.state || {}
|
||||
// Check both nextLocation and currentLocation state for successful submission
|
||||
// This handles browser history navigation (-1) where we set state on current location
|
||||
const { isSubmitSuccessful: nextIsSuccessful } = nextLocation.state || {}
|
||||
const { isSubmitSuccessful: currentIsSuccessful } =
|
||||
currentLocation.state || {}
|
||||
const isSubmitSuccessful = nextIsSuccessful || currentIsSuccessful
|
||||
|
||||
if (isSubmitSuccessful) {
|
||||
onClose?.(true)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
|
||||
import { Path, useNavigate } from "react-router-dom"
|
||||
import { Path, useLocation, useNavigate } from "react-router-dom"
|
||||
import { RouteModalProviderContext } from "./route-modal-context"
|
||||
|
||||
type RouteModalProviderProps = PropsWithChildren<{
|
||||
prev: string | Partial<Path>
|
||||
prev: string | Partial<Path> | number
|
||||
}>
|
||||
|
||||
export const RouteModalProvider = ({
|
||||
@@ -11,15 +11,27 @@ export const RouteModalProvider = ({
|
||||
children,
|
||||
}: RouteModalProviderProps) => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const [closeOnEscape, setCloseOnEscape] = useState(true)
|
||||
|
||||
const handleSuccess = useCallback(
|
||||
(path?: string) => {
|
||||
const to = path || prev
|
||||
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
|
||||
if (typeof to === "number") {
|
||||
// Replace current location with success state, then navigate back
|
||||
navigate(location.pathname + location.search, {
|
||||
replace: true,
|
||||
state: { ...location.state, isSubmitSuccessful: true },
|
||||
})
|
||||
setTimeout(() => {
|
||||
navigate(to)
|
||||
}, 0)
|
||||
} else {
|
||||
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
|
||||
}
|
||||
},
|
||||
[navigate, prev]
|
||||
[navigate, prev, location]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
Reference in New Issue
Block a user