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]":
|
||||
|
||||
+13
-4
@@ -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>
|
||||
|
||||
+103
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-3
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+7
-7
@@ -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}
|
||||
|
||||
+233
@@ -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,
|
||||
|
||||
+18
@@ -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])
|
||||
|
||||
|
||||
+1
@@ -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}".`)
|
||||
|
||||
+65
-7
@@ -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
-3
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
+16
-4
@@ -1,9 +1,9 @@
|
||||
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
|
||||
import { Path, useNavigate } from "react-router-dom"
|
||||
import { Path, useLocation, useNavigate } from "react-router-dom"
|
||||
import { RouteModalProviderContext } from "./route-modal-context"
|
||||
|
||||
type RouteModalProviderProps = PropsWithChildren<{
|
||||
prev: string | Partial<Path>
|
||||
prev: string | Partial<Path> | number
|
||||
}>
|
||||
|
||||
export const RouteModalProvider = ({
|
||||
@@ -11,15 +11,27 @@ export const RouteModalProvider = ({
|
||||
children,
|
||||
}: RouteModalProviderProps) => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const [closeOnEscape, setCloseOnEscape] = useState(true)
|
||||
|
||||
const handleSuccess = useCallback(
|
||||
(path?: string) => {
|
||||
const to = path || prev
|
||||
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
|
||||
if (typeof to === "number") {
|
||||
// Replace current location with success state, then navigate back
|
||||
navigate(location.pathname + location.search, {
|
||||
replace: true,
|
||||
state: { ...location.state, isSubmitSuccessful: true },
|
||||
})
|
||||
setTimeout(() => {
|
||||
navigate(to)
|
||||
}, 0)
|
||||
} else {
|
||||
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
|
||||
}
|
||||
},
|
||||
[navigate, prev]
|
||||
[navigate, prev, location]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -1840,6 +1840,29 @@ export function getRouteMap({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "translations",
|
||||
errorElement: <ErrorBoundary />,
|
||||
handle: {
|
||||
breadcrumb: () => t("translations.domain"),
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () =>
|
||||
import("../../routes/translations/translation-list"),
|
||||
},
|
||||
{
|
||||
path: "edit",
|
||||
lazy: () =>
|
||||
import("../../routes/translations/translations-edit"),
|
||||
},
|
||||
{
|
||||
path: "add-locales",
|
||||
lazy: () => import("../../routes/translations/add-locales"),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(settingsRoutes?.[0]?.children || []),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
UseInfiniteQueryOptions,
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
useMutation,
|
||||
@@ -11,6 +13,7 @@ import { sdk } from "../../lib/client"
|
||||
import { queryClient } from "../../lib/query-client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { productsQueryKeys } from "./products"
|
||||
import { useInfiniteList } from "../use-infinite-list"
|
||||
|
||||
const CATEGORIES_QUERY_KEY = "categories" as const
|
||||
export const categoriesQueryKeys = queryKeysFactory(CATEGORIES_QUERY_KEY)
|
||||
@@ -58,6 +61,35 @@ export const useProductCategories = (
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useInfiniteCategories = (
|
||||
query?: Omit<HttpTypes.AdminProductCategoryListParams, "offset" | "limit"> & {
|
||||
limit?: number
|
||||
},
|
||||
options?: Omit<
|
||||
UseInfiniteQueryOptions<
|
||||
HttpTypes.AdminProductCategoryListResponse,
|
||||
FetchError,
|
||||
InfiniteData<HttpTypes.AdminProductCategoryListResponse, number>,
|
||||
HttpTypes.AdminProductCategoryListResponse,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||
>
|
||||
) => {
|
||||
return useInfiniteList<
|
||||
HttpTypes.AdminProductCategoryListResponse,
|
||||
HttpTypes.AdminProductCategoryListParams,
|
||||
FetchError,
|
||||
QueryKey
|
||||
>({
|
||||
queryKey: (params) => categoriesQueryKeys.list(params),
|
||||
queryFn: (params) => sdk.admin.productCategory.list(params),
|
||||
query,
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useCreateProductCategory = (
|
||||
options?: UseMutationOptions<
|
||||
HttpTypes.AdminProductCategoryResponse,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { FindParams, HttpTypes, PaginatedResponse } from "@medusajs/types"
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
UseInfiniteQueryOptions,
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
useMutation,
|
||||
@@ -11,6 +13,7 @@ import { sdk } from "../../lib/client"
|
||||
import { queryClient } from "../../lib/query-client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { productsQueryKeys } from "./products"
|
||||
import { useInfiniteList } from "../use-infinite-list"
|
||||
|
||||
const COLLECTION_QUERY_KEY = "collections" as const
|
||||
export const collectionsQueryKeys = queryKeysFactory(COLLECTION_QUERY_KEY)
|
||||
@@ -57,6 +60,34 @@ export const useCollections = (
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useInfiniteCollections = (
|
||||
query?: Omit<HttpTypes.AdminCollectionListParams, "offset" | "limit"> & {
|
||||
limit?: number
|
||||
},
|
||||
options?: Omit<
|
||||
UseInfiniteQueryOptions<
|
||||
HttpTypes.AdminCollectionListResponse,
|
||||
FetchError,
|
||||
InfiniteData<HttpTypes.AdminCollectionListResponse, number>,
|
||||
HttpTypes.AdminCollectionListResponse,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||
>
|
||||
) => {
|
||||
return useInfiniteList<
|
||||
HttpTypes.AdminCollectionListResponse,
|
||||
HttpTypes.AdminCollectionListParams,
|
||||
FetchError,
|
||||
QueryKey
|
||||
>({
|
||||
queryKey: (params) => collectionsQueryKeys.list(params),
|
||||
queryFn: (params) => sdk.admin.productCollection.list(params),
|
||||
query,
|
||||
options,
|
||||
})
|
||||
}
|
||||
export const useUpdateCollection = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<
|
||||
|
||||
@@ -34,6 +34,7 @@ export * from "./store"
|
||||
export * from "./tags"
|
||||
export * from "./tax-rates"
|
||||
export * from "./tax-regions"
|
||||
export * from "./translations"
|
||||
export * from "./users"
|
||||
export * from "./views"
|
||||
export * from "./workflow-executions"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
UseInfiniteQueryOptions,
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
useMutation,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
import { sdk } from "../../lib/client"
|
||||
import { queryClient } from "../../lib/query-client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { useInfiniteList } from "../use-infinite-list"
|
||||
|
||||
const PRODUCT_TYPES_QUERY_KEY = "product_types" as const
|
||||
export const productTypesQueryKeys = queryKeysFactory(PRODUCT_TYPES_QUERY_KEY)
|
||||
@@ -57,6 +60,35 @@ export const useProductTypes = (
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useInfiniteProductTypes = (
|
||||
query?: Omit<HttpTypes.AdminProductTypeListParams, "offset" | "limit"> & {
|
||||
limit?: number
|
||||
},
|
||||
options?: Omit<
|
||||
UseInfiniteQueryOptions<
|
||||
HttpTypes.AdminProductTypeListResponse,
|
||||
FetchError,
|
||||
InfiniteData<HttpTypes.AdminProductTypeListResponse, number>,
|
||||
HttpTypes.AdminProductTypeListResponse,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||
>
|
||||
) => {
|
||||
return useInfiniteList<
|
||||
HttpTypes.AdminProductTypeListResponse,
|
||||
HttpTypes.AdminProductTypeListParams,
|
||||
FetchError,
|
||||
QueryKey
|
||||
>({
|
||||
queryKey: (params) => productTypesQueryKeys.list(params),
|
||||
queryFn: (params) => sdk.admin.productType.list(params),
|
||||
query,
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useCreateProductType = (
|
||||
options?: UseMutationOptions<
|
||||
HttpTypes.AdminProductTypeResponse,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
|
||||
import {
|
||||
QueryKey,
|
||||
UseInfiniteQueryOptions,
|
||||
useQuery,
|
||||
UseQueryOptions,
|
||||
} from "@tanstack/react-query"
|
||||
import { InfiniteData } from "@tanstack/query-core"
|
||||
import { sdk } from "../../lib/client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { useInfiniteList } from "../use-infinite-list"
|
||||
|
||||
const PRODUCT_VARIANT_QUERY_KEY = "product_variant" as const
|
||||
export const productVariantQueryKeys = queryKeysFactory(
|
||||
@@ -9,9 +17,14 @@ export const productVariantQueryKeys = queryKeysFactory(
|
||||
)
|
||||
|
||||
export const useVariants = (
|
||||
query?: Record<string, any>,
|
||||
query?: HttpTypes.AdminProductVariantParams,
|
||||
options?: Omit<
|
||||
UseQueryOptions<any, FetchError, any, QueryKey>,
|
||||
UseQueryOptions<
|
||||
HttpTypes.AdminProductVariantListResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminProductVariantListResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>
|
||||
) => {
|
||||
@@ -23,3 +36,34 @@ export const useVariants = (
|
||||
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useInfiniteVariants = (
|
||||
query?: Omit<HttpTypes.AdminProductVariantParams, "offset" | "limit"> & {
|
||||
limit?: number
|
||||
},
|
||||
options?: Omit<
|
||||
UseInfiniteQueryOptions<
|
||||
HttpTypes.AdminProductVariantListResponse,
|
||||
FetchError,
|
||||
InfiniteData<HttpTypes.AdminProductVariantListResponse, number>,
|
||||
HttpTypes.AdminProductVariantListResponse,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||
>
|
||||
) => {
|
||||
return useInfiniteList<
|
||||
HttpTypes.AdminProductVariantListResponse,
|
||||
HttpTypes.AdminProductVariantParams,
|
||||
FetchError,
|
||||
QueryKey
|
||||
>({
|
||||
queryKey: (params) => productVariantQueryKeys.list(params),
|
||||
queryFn: async (params) => {
|
||||
return await sdk.admin.productVariant.list(params)
|
||||
},
|
||||
query,
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,15 +2,18 @@ import { FetchError } from "@medusajs/js-sdk"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
QueryKey,
|
||||
UseInfiniteQueryOptions,
|
||||
useMutation,
|
||||
UseMutationOptions,
|
||||
useQuery,
|
||||
UseQueryOptions,
|
||||
} from "@tanstack/react-query"
|
||||
import { InfiniteData } from "@tanstack/query-core"
|
||||
import { sdk } from "../../lib/client"
|
||||
import { queryClient } from "../../lib/query-client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { inventoryItemsQueryKeys } from "./inventory.tsx"
|
||||
import { useInfiniteList } from "../use-infinite-list.tsx"
|
||||
|
||||
const PRODUCTS_QUERY_KEY = "products" as const
|
||||
export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY)
|
||||
@@ -310,6 +313,32 @@ export const useProducts = (
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useInfiniteProducts = (
|
||||
query?: HttpTypes.AdminProductListParams,
|
||||
options?: Omit<
|
||||
UseInfiniteQueryOptions<
|
||||
HttpTypes.AdminProductListResponse,
|
||||
FetchError,
|
||||
InfiniteData<HttpTypes.AdminProductListResponse, number>,
|
||||
HttpTypes.AdminProductListResponse,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||
>
|
||||
) => {
|
||||
return useInfiniteList<
|
||||
HttpTypes.AdminProductListResponse,
|
||||
HttpTypes.AdminProductListParams,
|
||||
FetchError,
|
||||
QueryKey
|
||||
>({
|
||||
queryKey: (params) => productsQueryKeys.list(params),
|
||||
queryFn: (params) => sdk.admin.product.list(params),
|
||||
query,
|
||||
options,
|
||||
})
|
||||
}
|
||||
export const useCreateProduct = (
|
||||
options?: UseMutationOptions<
|
||||
HttpTypes.AdminProductResponse,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
UseInfiniteQueryOptions,
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
useMutation,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
import { sdk } from "../../lib/client"
|
||||
import { queryClient } from "../../lib/query-client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { useInfiniteList } from "../use-infinite-list"
|
||||
|
||||
const TAGS_QUERY_KEY = "tags" as const
|
||||
export const productTagsQueryKeys = queryKeysFactory(TAGS_QUERY_KEY)
|
||||
@@ -57,6 +60,35 @@ export const useProductTags = (
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useInfiniteProductTags = (
|
||||
query?: Omit<HttpTypes.AdminProductTagListParams, "offset" | "limit"> & {
|
||||
limit?: number
|
||||
},
|
||||
options?: Omit<
|
||||
UseInfiniteQueryOptions<
|
||||
HttpTypes.AdminProductTagListResponse,
|
||||
FetchError,
|
||||
InfiniteData<HttpTypes.AdminProductTagListResponse, number>,
|
||||
HttpTypes.AdminProductTagListResponse,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||
>
|
||||
) => {
|
||||
return useInfiniteList<
|
||||
HttpTypes.AdminProductTagListResponse,
|
||||
HttpTypes.AdminProductTagListParams,
|
||||
FetchError,
|
||||
QueryKey
|
||||
>({
|
||||
queryKey: (params) => productTagsQueryKeys.list(params),
|
||||
queryFn: (params) => sdk.admin.productTag.list(params),
|
||||
query,
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useCreateProductTag = (
|
||||
query?: HttpTypes.AdminProductTagParams,
|
||||
options?: UseMutationOptions<
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
QueryKey,
|
||||
UseInfiniteQueryOptions,
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
UseMutationOptions,
|
||||
useQuery,
|
||||
UseQueryOptions,
|
||||
} from "@tanstack/react-query"
|
||||
import { sdk } from "../../lib/client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import { queryClient } from "../../lib/query-client"
|
||||
import { productsQueryKeys, useInfiniteProducts } from "./products"
|
||||
import {
|
||||
productVariantQueryKeys,
|
||||
useInfiniteVariants,
|
||||
} from "./product-variants"
|
||||
import { categoriesQueryKeys, useInfiniteCategories } from "./categories"
|
||||
import { collectionsQueryKeys, useInfiniteCollections } from "./collections"
|
||||
import { productTagsQueryKeys, useInfiniteProductTags } from "./tags"
|
||||
import { productTypesQueryKeys, useInfiniteProductTypes } from "./product-types"
|
||||
|
||||
const TRANSLATIONS_QUERY_KEY = "translations" as const
|
||||
export const translationsQueryKeys = queryKeysFactory(TRANSLATIONS_QUERY_KEY)
|
||||
|
||||
const TRANSLATION_SETTINGS_QUERY_KEY = "translation_settings" as const
|
||||
export const translationSettingsQueryKeys = queryKeysFactory(
|
||||
TRANSLATION_SETTINGS_QUERY_KEY
|
||||
)
|
||||
|
||||
const TRANSLATION_STATISTICS_QUERY_KEY = "translation_statistics" as const
|
||||
export const translationStatisticsQueryKeys = queryKeysFactory(
|
||||
TRANSLATION_STATISTICS_QUERY_KEY
|
||||
)
|
||||
|
||||
export const useReferenceTranslations = (
|
||||
reference: string,
|
||||
translatableFields: string[],
|
||||
referenceId?: string | string[],
|
||||
options?: Omit<
|
||||
UseInfiniteQueryOptions<any, FetchError, any, any, QueryKey, number>,
|
||||
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||
>
|
||||
) => {
|
||||
const referenceHookMap = new Map<
|
||||
string,
|
||||
() => Omit<UseInfiniteQueryResult<any, FetchError>, "data"> & {
|
||||
data: {
|
||||
translations: HttpTypes.AdminTranslation[]
|
||||
references: (Record<string, any> & { id: string })[]
|
||||
count: number
|
||||
}
|
||||
}
|
||||
>([
|
||||
[
|
||||
"product",
|
||||
() => {
|
||||
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||
|
||||
const { data, ...rest } = useInfiniteProducts(
|
||||
{ fields, id: referenceId ?? [] },
|
||||
options
|
||||
)
|
||||
const products = data?.pages.flatMap((page) => page.products) ?? []
|
||||
|
||||
return {
|
||||
...rest,
|
||||
data: {
|
||||
translations:
|
||||
products?.flatMap((product) => product.translations ?? []) ?? [],
|
||||
references: products ?? [],
|
||||
count: data?.pages[0]?.count ?? 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
"product_variant",
|
||||
() => {
|
||||
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||
|
||||
const { data, ...rest } = useInfiniteVariants(
|
||||
{ id: referenceId ?? [], fields },
|
||||
options
|
||||
)
|
||||
const variants = data?.pages.flatMap((page) => page.variants) ?? []
|
||||
|
||||
return {
|
||||
...rest,
|
||||
data: {
|
||||
translations:
|
||||
variants?.flatMap((variant) => variant.translations ?? []) ?? [],
|
||||
references: variants ?? [],
|
||||
translatableFields,
|
||||
count: data?.pages[0]?.count ?? 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
"product_category",
|
||||
() => {
|
||||
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||
|
||||
const { data, ...rest } = useInfiniteCategories(
|
||||
{ id: referenceId ?? [], fields },
|
||||
options
|
||||
)
|
||||
const categories =
|
||||
data?.pages.flatMap((page) => page.product_categories) ?? []
|
||||
|
||||
return {
|
||||
...rest,
|
||||
data: {
|
||||
translations:
|
||||
categories?.flatMap((category) => category.translations ?? []) ??
|
||||
[],
|
||||
references: categories ?? [],
|
||||
translatableFields,
|
||||
count: data?.pages[0]?.count ?? 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
"product_collection",
|
||||
() => {
|
||||
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||
|
||||
const { data, ...rest } = useInfiniteCollections(
|
||||
{ id: referenceId ?? [], fields },
|
||||
options
|
||||
)
|
||||
const collections =
|
||||
data?.pages.flatMap((page) => page.collections) ?? []
|
||||
|
||||
return {
|
||||
...rest,
|
||||
data: {
|
||||
translations:
|
||||
collections?.flatMap(
|
||||
(collection) => collection.translations ?? []
|
||||
) ?? [],
|
||||
references: collections ?? [],
|
||||
translatableFields,
|
||||
count: data?.pages[0]?.count ?? 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
"product_type",
|
||||
() => {
|
||||
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||
|
||||
const { data, ...rest } = useInfiniteProductTypes(
|
||||
{ id: referenceId ?? [], fields },
|
||||
options
|
||||
)
|
||||
const product_types =
|
||||
data?.pages.flatMap((page) => page.product_types) ?? []
|
||||
|
||||
return {
|
||||
...rest,
|
||||
data: {
|
||||
translations:
|
||||
product_types?.flatMap((type) => type.translations ?? []) ?? [],
|
||||
references: product_types ?? [],
|
||||
count: data?.pages[0]?.count ?? 0,
|
||||
translatableFields,
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
"product_tag",
|
||||
() => {
|
||||
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||
|
||||
const { data, ...rest } = useInfiniteProductTags(
|
||||
{ id: referenceId ?? [], fields },
|
||||
options
|
||||
)
|
||||
const product_tags =
|
||||
data?.pages.flatMap((page) => page.product_tags) ?? []
|
||||
|
||||
return {
|
||||
...rest,
|
||||
data: {
|
||||
translations:
|
||||
product_tags?.flatMap((tag) => tag.translations ?? []) ?? [],
|
||||
references: product_tags ?? [],
|
||||
translatableFields,
|
||||
count: data?.pages[0]?.count ?? 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
// TODO: product option and option values
|
||||
])
|
||||
const referenceHook = referenceHookMap.get(reference)
|
||||
if (!referenceHook) {
|
||||
throw new Error(`No hook found for reference type: ${reference}`)
|
||||
}
|
||||
const { data, ...rest } = referenceHook()
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useTranslations = (
|
||||
query?: HttpTypes.AdminTranslationsListParams,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
HttpTypes.AdminTranslationsListResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminTranslationsListResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryKey: translationsQueryKeys.list(query),
|
||||
queryFn: () => sdk.admin.translation.list(query),
|
||||
...options,
|
||||
})
|
||||
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
const referenceInvalidationKeysMap = new Map<string, QueryKey>([
|
||||
["product", productsQueryKeys.lists()],
|
||||
["product_variant", productVariantQueryKeys.lists()],
|
||||
["product_category", categoriesQueryKeys.lists()],
|
||||
["product_collection", collectionsQueryKeys.lists()],
|
||||
["product_type", productTypesQueryKeys.lists()],
|
||||
["product_tag", productTagsQueryKeys.lists()],
|
||||
])
|
||||
|
||||
export const useBatchTranslations = (
|
||||
reference: string,
|
||||
options?: UseMutationOptions<
|
||||
HttpTypes.AdminTranslationsBatchResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminBatchTranslations
|
||||
>
|
||||
) => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: HttpTypes.AdminBatchTranslations) =>
|
||||
sdk.admin.translation.batch(payload),
|
||||
...options,
|
||||
})
|
||||
|
||||
/**
|
||||
* Useful to call the invalidation separately from the batch request and await the refetch finishes.
|
||||
*/
|
||||
const invalidateQueries = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: referenceInvalidationKeysMap.get(reference),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: translationStatisticsQueryKeys.lists(),
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
invalidateQueries,
|
||||
}
|
||||
}
|
||||
|
||||
export const useTranslationSettings = (
|
||||
query?: HttpTypes.AdminTranslationSettingsParams,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
HttpTypes.AdminTranslationSettingsResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminTranslationSettingsResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryKey: translationSettingsQueryKeys.list(query),
|
||||
queryFn: () => sdk.admin.translation.settings(query),
|
||||
...options,
|
||||
})
|
||||
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useTranslationStatistics = (
|
||||
query?: HttpTypes.AdminTranslationStatisticsParams,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
HttpTypes.AdminTranslationStatisticsResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminTranslationStatisticsResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryKey: translationStatisticsQueryKeys.list(query),
|
||||
queryFn: () => sdk.admin.translation.statistics(query),
|
||||
...options,
|
||||
})
|
||||
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { FetchError } from "@medusajs/js-sdk"
|
||||
import { PaginatedResponse } from "@medusajs/types"
|
||||
import {
|
||||
QueryKey,
|
||||
UseInfiniteQueryOptions,
|
||||
InfiniteData,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query"
|
||||
|
||||
/**
|
||||
* Generic hook for infinite queries with pagination support.
|
||||
*
|
||||
* @template TResponse - The response type that must include count, offset, and limit
|
||||
* @template TParams - The query parameters type (offset and limit will be handled internally)
|
||||
* @template TError - The error type (defaults to FetchError)
|
||||
* @template TQueryKey - The query key type (defaults to QueryKey)
|
||||
*
|
||||
* @param config - Configuration object
|
||||
* @param config.queryKey - Function or array that generates the query key
|
||||
* @param config.queryFn - Function that fetches data with offset and limit
|
||||
* @param config.query - Query parameters (offset is ignored, limit is optional)
|
||||
* @param config.options - Additional options for useInfiniteQuery
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { data, fetchNextPage, hasNextPage } = useInfiniteList({
|
||||
* queryKey: (params) => productVariantQueryKeys.list(params),
|
||||
* queryFn: async (params) => sdk.admin.productVariant.list(params),
|
||||
* query: { status: "published" },
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const useInfiniteList = <
|
||||
TResponse extends PaginatedResponse<unknown>,
|
||||
TParams extends { offset?: number; limit?: number } = {
|
||||
offset?: number
|
||||
limit?: number
|
||||
},
|
||||
TError = FetchError,
|
||||
TQueryKey extends QueryKey = QueryKey
|
||||
>({
|
||||
queryKey,
|
||||
queryFn,
|
||||
query,
|
||||
options,
|
||||
}: {
|
||||
queryKey: ((params: Omit<TParams, "limit">) => TQueryKey) | TQueryKey
|
||||
queryFn: (params: TParams) => Promise<TResponse>
|
||||
query?: TParams
|
||||
options?: Omit<
|
||||
UseInfiniteQueryOptions<
|
||||
TResponse,
|
||||
TError,
|
||||
InfiniteData<TResponse, number>,
|
||||
TResponse,
|
||||
TQueryKey,
|
||||
number
|
||||
>,
|
||||
"queryKey" | "queryFn" | "initialPageParam" | "getNextPageParam"
|
||||
>
|
||||
}) => {
|
||||
const { limit = 50, offset: _, ..._query } = query ?? {}
|
||||
const resolvedQueryKey =
|
||||
typeof queryKey === "function"
|
||||
? queryKey(_query as Omit<TParams, "limit">)
|
||||
: queryKey
|
||||
const infiniteQueryKey =
|
||||
resolvedQueryKey[resolvedQueryKey.length - 1] === "__infinite"
|
||||
? resolvedQueryKey
|
||||
: ([...resolvedQueryKey, "__infinite"] as unknown as TQueryKey)
|
||||
|
||||
return useInfiniteQuery<
|
||||
TResponse,
|
||||
TError,
|
||||
InfiniteData<TResponse, number>,
|
||||
TQueryKey,
|
||||
number
|
||||
>({
|
||||
// Infinite queries must not share the exact same queryKey as non-infinite queries,
|
||||
// since the cached data shape differs (InfiniteData<T> vs T).
|
||||
queryKey: infiniteQueryKey,
|
||||
queryFn: ({ pageParam = 0 }) => {
|
||||
return queryFn({ ..._query, limit, offset: pageParam } as TParams)
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -38,6 +38,9 @@
|
||||
"search": {
|
||||
"type": "string"
|
||||
},
|
||||
"original": {
|
||||
"type": "string"
|
||||
},
|
||||
"of": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -104,6 +107,9 @@
|
||||
"removed": {
|
||||
"type": "string"
|
||||
},
|
||||
"remaining": {
|
||||
"type": "string"
|
||||
},
|
||||
"admin": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -191,6 +197,7 @@
|
||||
"apply",
|
||||
"range",
|
||||
"search",
|
||||
"original",
|
||||
"of",
|
||||
"results",
|
||||
"pages",
|
||||
@@ -213,6 +220,7 @@
|
||||
"modified",
|
||||
"added",
|
||||
"removed",
|
||||
"remaining",
|
||||
"admin",
|
||||
"store",
|
||||
"details",
|
||||
@@ -381,6 +389,12 @@
|
||||
"save": {
|
||||
"type": "string"
|
||||
},
|
||||
"saveChanges": {
|
||||
"type": "string"
|
||||
},
|
||||
"saveAndClose": {
|
||||
"type": "string"
|
||||
},
|
||||
"saveAsDraft": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -513,7 +527,9 @@
|
||||
},
|
||||
"required": [
|
||||
"save",
|
||||
"saveChanges",
|
||||
"saveAsDraft",
|
||||
"saveAndClose",
|
||||
"copy",
|
||||
"copied",
|
||||
"duplicate",
|
||||
@@ -9328,6 +9344,112 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"translations": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"list": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"metrics": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["metrics"]
|
||||
},
|
||||
"actions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"manageLocales": {
|
||||
"type": "string"
|
||||
},
|
||||
"manage": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["manageLocales", "manage"]
|
||||
},
|
||||
"edit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"successToast": {
|
||||
"type": "string"
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["title", "description"]
|
||||
}
|
||||
},
|
||||
"required": ["successToast", "unsavedChanges"]
|
||||
},
|
||||
"bulk": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"header": {
|
||||
"type": "string"
|
||||
},
|
||||
"mainColumn": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["header"]
|
||||
},
|
||||
"activeLocales": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"heading": {
|
||||
"type": "string"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"noLocalesTip": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["heading", "subtitle", "noLocalesTip"]
|
||||
},
|
||||
"completion": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"heading": {
|
||||
"type": "string"
|
||||
},
|
||||
"translated": {
|
||||
"type": "string"
|
||||
},
|
||||
"toTranslate": {
|
||||
"type": "string"
|
||||
},
|
||||
"footer": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["heading", "translated", "toTranslate", "footer"]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"domain",
|
||||
"actions",
|
||||
"subtitle",
|
||||
"bulk",
|
||||
"completion",
|
||||
"edit"
|
||||
]
|
||||
},
|
||||
"store": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"apply": "Apply",
|
||||
"range": "Range",
|
||||
"search": "Search",
|
||||
"original": "Original",
|
||||
"of": "of",
|
||||
"results": "results",
|
||||
"pages": "pages",
|
||||
@@ -33,6 +34,7 @@
|
||||
"modified": "Modified",
|
||||
"added": "Added",
|
||||
"removed": "Removed",
|
||||
"remaining": "Left",
|
||||
"admin": "Admin",
|
||||
"store": "Store",
|
||||
"details": "Details",
|
||||
@@ -99,7 +101,9 @@
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"saveChanges": "Save changes",
|
||||
"saveAsDraft": "Save as draft",
|
||||
"saveAndClose": "Save and close",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"duplicate": "Duplicate",
|
||||
@@ -2504,6 +2508,40 @@
|
||||
"deleteUserSuccess": "User {{name}} deleted successfully",
|
||||
"invite": "Invite"
|
||||
},
|
||||
"translations": {
|
||||
"domain": "Translations",
|
||||
"actions": {
|
||||
"manage": "Manage translations",
|
||||
"manageLocales": "Manage locales"
|
||||
},
|
||||
"title": "Translation domains",
|
||||
"subtitle": "Manage translations of your data in Medusa",
|
||||
"list": {
|
||||
"metrics": "{{translated}} of {{total}} fields translated"
|
||||
},
|
||||
"edit": {
|
||||
"successToast": "Translations updated successfully",
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved translations",
|
||||
"description": "Don't lose your work. You have changes that haven't been saved yet"
|
||||
}
|
||||
},
|
||||
"bulk": {
|
||||
"header": "Translations Bulk Editor",
|
||||
"mainColumn": "Locale"
|
||||
},
|
||||
"activeLocales": {
|
||||
"heading": "Languages",
|
||||
"subtitle": "Activated translations",
|
||||
"noLocalesTip": "Configure at least one locale to start translating your data"
|
||||
},
|
||||
"completion": {
|
||||
"heading": "Translated fields",
|
||||
"translated": "Translated",
|
||||
"toTranslate": "Missing",
|
||||
"footer": "Languages"
|
||||
}
|
||||
},
|
||||
"store": {
|
||||
"domain": "Store",
|
||||
"manageYourStoresDetails": "Manage your store's details",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"apply": "Aplicar",
|
||||
"range": "Rango",
|
||||
"search": "Buscar",
|
||||
"original": "Original",
|
||||
"of": "de",
|
||||
"results": "resultados",
|
||||
"pages": "páginas",
|
||||
@@ -33,6 +34,7 @@
|
||||
"modified": "Modificado",
|
||||
"added": "Agregado",
|
||||
"removed": "Eliminado",
|
||||
"remaining": "Restantes",
|
||||
"admin": "Administrador",
|
||||
"store": "Tienda",
|
||||
"details": "Detalles",
|
||||
@@ -120,7 +122,9 @@
|
||||
},
|
||||
"actions": {
|
||||
"save": "Guardar",
|
||||
"saveChanges": "Guardar cambios",
|
||||
"saveAsDraft": "Guardar como borrador",
|
||||
"saveAndClose": "Guardar y cerrar",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -2443,6 +2447,40 @@
|
||||
"deleteUserSuccess": "Usuario {{name}} eliminado correctamente",
|
||||
"invite": "Invitar"
|
||||
},
|
||||
"translations": {
|
||||
"domain": "Traducciones",
|
||||
"list": {
|
||||
"metrics": "{{translated}} de {{total}} textos traducidos"
|
||||
},
|
||||
"actions": {
|
||||
"manage": "Gestionar traducciones",
|
||||
"manageLocales": "Gestionar idiomas"
|
||||
},
|
||||
"title": "Traducción de dominios",
|
||||
"subtitle": "Gestiona las traducciones de tus datos en Medusa",
|
||||
"bulk": {
|
||||
"header": "Editor de Traducciones en Masa",
|
||||
"locale": "Idioma"
|
||||
},
|
||||
"edit": {
|
||||
"successToast": "Traducciones actualizadas exitosamente",
|
||||
"unsavedChanges": {
|
||||
"title": "Traducciones sin guardar",
|
||||
"description": "No pierdas tu trabajo. Tienes cambios que no han sido guardados aún"
|
||||
}
|
||||
},
|
||||
"activeLocales": {
|
||||
"heading": "Idiomas",
|
||||
"subtitle": "Traducciones activas",
|
||||
"noLocalesTip": "Configura al menos un idioma para empezar a traducir tu información"
|
||||
},
|
||||
"completion": {
|
||||
"heading": "Textos traducidos",
|
||||
"translated": "Traducidos",
|
||||
"toTranslate": "Faltantes",
|
||||
"footer": "Idiomas"
|
||||
}
|
||||
},
|
||||
"store": {
|
||||
"domain": "Tienda",
|
||||
"manageYourStoresDetails": "Gestiona los detalles de tu tienda",
|
||||
|
||||
+16
-1
@@ -1,10 +1,11 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Container, Heading, StatusBadge, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
|
||||
import { getIsActiveProps, getIsInternalProps } from "../../../common/utils"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
type CategoryGeneralSectionProps = {
|
||||
category: HttpTypes.AdminProductCategory
|
||||
@@ -14,6 +15,7 @@ export const CategoryGeneralSection = ({
|
||||
category,
|
||||
}: CategoryGeneralSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
const activeProps = getIsActiveProps(category.is_active, t)
|
||||
const internalProps = getIsInternalProps(category.is_internal, t)
|
||||
@@ -44,6 +46,19 @@ export const CategoryGeneralSection = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_category&reference_id=${category.id}`,
|
||||
icon: <GlobeEurope />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { AdminProductCategoryResponse } from "@medusajs/types"
|
||||
import { Button, Container, Heading, Text } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
@@ -14,6 +14,7 @@ import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
|
||||
import { useCategoryTableColumns } from "./use-category-table-columns"
|
||||
import { useCategoryTableQuery } from "./use-category-table-query"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -105,6 +106,7 @@ const CategoryRowActions = ({
|
||||
category: AdminProductCategoryResponse["product_category"]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
const handleDelete = useDeleteProductCategoryAction(category)
|
||||
|
||||
return (
|
||||
@@ -119,6 +121,19 @@ const CategoryRowActions = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <GlobeEurope />,
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_category&reference_id=${category.id}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -1,10 +1,11 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Container, Heading, Text, usePrompt } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDeleteCollection } from "../../../../../hooks/api/collections"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
type CollectionGeneralSectionProps = {
|
||||
collection: HttpTypes.AdminCollection
|
||||
@@ -16,6 +17,7 @@ export const CollectionGeneralSection = ({
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const navigate = useNavigate()
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
const { mutateAsync } = useDeleteCollection(collection.id!)
|
||||
|
||||
@@ -52,6 +54,19 @@ export const CollectionGeneralSection = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_collection&reference_id=${collection.id}`,
|
||||
icon: <GlobeEurope />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -1,10 +1,11 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { usePrompt } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDeleteCollection } from "../../../../../hooks/api/collections"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
export const CollectionRowActions = ({
|
||||
collection,
|
||||
@@ -13,6 +14,7 @@ export const CollectionRowActions = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
const { mutateAsync } = useDeleteCollection(collection.id!)
|
||||
|
||||
@@ -47,6 +49,19 @@ export const CollectionRowActions = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <GlobeEurope />,
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_collection&reference_id=${collection.id}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -1,9 +1,10 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
type ProductTagGeneralSectionProps = {
|
||||
productTag: HttpTypes.AdminProductTag
|
||||
@@ -14,6 +15,7 @@ export const ProductTagGeneralSection = ({
|
||||
}: ProductTagGeneralSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const handleDelete = useDeleteProductTagAction({ productTag })
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
return (
|
||||
<Container className="flex items-center justify-between">
|
||||
@@ -32,6 +34,19 @@ export const ProductTagGeneralSection = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_tag&reference_id=${productTag.id}`,
|
||||
icon: <GlobeEurope />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button, Container, Heading } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
@@ -16,6 +16,7 @@ import { useProductTagTableQuery } from "../../../../../hooks/table/query"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
|
||||
import { productTagListLoader } from "../../loader"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -88,6 +89,7 @@ const ProductTagRowActions = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const handleDelete = useDeleteProductTagAction({ productTag })
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
@@ -101,6 +103,19 @@ const ProductTagRowActions = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <GlobeEurope />,
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_tag&reference_id=${productTag.id}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -1,9 +1,10 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
type ProductTypeGeneralSectionProps = {
|
||||
productType: HttpTypes.AdminProductType
|
||||
@@ -17,6 +18,7 @@ export const ProductTypeGeneralSection = ({
|
||||
productType.id,
|
||||
productType.value
|
||||
)
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
return (
|
||||
<Container className="flex items-center justify-between">
|
||||
@@ -32,6 +34,19 @@ export const ProductTypeGeneralSection = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_type&reference_id=${productType.id}`,
|
||||
icon: <GlobeEurope />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -1,8 +1,9 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
type ProductTypeRowActionsProps = {
|
||||
productType: HttpTypes.AdminProductType
|
||||
@@ -16,6 +17,7 @@ export const ProductTypeRowActions = ({
|
||||
productType.id,
|
||||
productType.value
|
||||
)
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
@@ -29,6 +31,19 @@ export const ProductTypeRowActions = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <GlobeEurope />,
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_type&reference_id=${productType.id}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import { Component, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { Component, GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -7,6 +7,7 @@ import { useNavigate } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { SectionRow } from "../../../../../components/common/section"
|
||||
import { useDeleteVariant } from "../../../../../hooks/api/products"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
type VariantGeneralSectionProps = {
|
||||
variant: HttpTypes.AdminProductVariant
|
||||
@@ -16,6 +17,7 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const navigate = useNavigate()
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
const hasInventoryKit = variant.inventory?.length > 1
|
||||
|
||||
@@ -70,6 +72,19 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_variant&reference_id=${variant.id}`,
|
||||
icon: <GlobeEurope />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Container, Heading, StatusBadge, toast, usePrompt } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -8,6 +8,7 @@ import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { SectionRow } from "../../../../../components/common/section"
|
||||
import { useDeleteProduct } from "../../../../../hooks/api/products"
|
||||
import { useExtension } from "../../../../../providers/extension-provider"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
const productStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -35,6 +36,7 @@ export const ProductGeneralSection = ({
|
||||
const prompt = usePrompt()
|
||||
const navigate = useNavigate()
|
||||
const { getDisplays } = useExtension()
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
const displays = getDisplays("product", "general")
|
||||
|
||||
@@ -85,6 +87,19 @@ export const ProductGeneralSection = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product&reference_id=${product.id}`,
|
||||
icon: <GlobeEurope />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
+32
-1
@@ -1,4 +1,10 @@
|
||||
import { Buildings, Component, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import {
|
||||
Buildings,
|
||||
Component,
|
||||
GlobeEurope,
|
||||
PencilSquare,
|
||||
Trash,
|
||||
} from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
Badge,
|
||||
@@ -26,6 +32,7 @@ import {
|
||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||
import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants"
|
||||
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
||||
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||
|
||||
type ProductVariantSectionProps = {
|
||||
product: HttpTypes.AdminProduct
|
||||
@@ -38,6 +45,7 @@ export const ProductVariantSection = ({
|
||||
product,
|
||||
}: ProductVariantSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
const { q, order, offset, allow_backorder, manage_inventory } =
|
||||
useQueryParams(
|
||||
@@ -70,6 +78,11 @@ export const ProductVariantSection = ({
|
||||
}
|
||||
)
|
||||
|
||||
const translationParams = new URLSearchParams()
|
||||
variants?.forEach((variant) => {
|
||||
translationParams.append("reference_id", variant.id)
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
@@ -115,6 +128,15 @@ export const ProductVariantSection = ({
|
||||
to: `stock`,
|
||||
icon: <Buildings />,
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
icon: <GlobeEurope />,
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product_variant&${translationParams.toString()}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -225,6 +247,15 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <GlobeEurope />,
|
||||
label: t("translations.actions.manage"),
|
||||
onClick: () => {
|
||||
navigate(
|
||||
`/settings/translations/edit?reference=product_variant&reference_id=${variant.id}`
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const secondaryActions: DataTableAction<HttpTypes.AdminProductVariant>[] =
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { GlobeEurope, PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { Button, Container, Heading, toast, usePrompt } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
@@ -110,6 +110,7 @@ const ProductActions = ({ product }: { product: HttpTypes.AdminProduct }) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const { mutateAsync } = useDeleteProduct(product.id)
|
||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
@@ -153,6 +154,19 @@ const ProductActions = ({ product }: { product: HttpTypes.AdminProduct }) => {
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isTranslationsEnabled
|
||||
? [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <GlobeEurope />,
|
||||
label: t("translations.actions.manage"),
|
||||
to: `/settings/translations/edit?reference=product&reference_id=${product.id}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { RouteFocusModal } from "../../../components/modals/route-focus-modal"
|
||||
import { useStore } from "../../../hooks/api"
|
||||
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { AddLocalesForm } from "../../store/store-add-locales/components/add-locales-form/add-locales-form"
|
||||
|
||||
export const TranslationsAddLocales = () => {
|
||||
const isEnabled = useFeatureFlag("translation")
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!isEnabled) {
|
||||
navigate(-1)
|
||||
return null
|
||||
}
|
||||
|
||||
const { store, isPending, isError, error } = useStore()
|
||||
|
||||
const ready = !!store && !isPending
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{ready && <AddLocalesForm store={store} />}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TranslationsAddLocales as Component } from "./add-locales"
|
||||
+93
@@ -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>
|
||||
)
|
||||
}
|
||||
+48
@@ -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>
|
||||
)
|
||||
}
|
||||
+285
@@ -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"
|
||||
+138
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./translations-edit-form"
|
||||
+747
@@ -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"
|
||||
+90
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user