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:
69 files changed
+3595
-112
No files matched your search
@@ -4,6 +4,7 @@ import { PropsWithChildren } from "react"
|
|||||||
type IconAvatarProps = PropsWithChildren<{
|
type IconAvatarProps = PropsWithChildren<{
|
||||||
className?: string
|
className?: string
|
||||||
size?: "small" | "large" | "xlarge"
|
size?: "small" | "large" | "xlarge"
|
||||||
|
variant?: "squared" | "rounded"
|
||||||
}>
|
}>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +14,7 @@ type IconAvatarProps = PropsWithChildren<{
|
|||||||
*/
|
*/
|
||||||
export const IconAvatar = ({
|
export const IconAvatar = ({
|
||||||
size = "small",
|
size = "small",
|
||||||
|
variant = "rounded",
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: IconAvatarProps) => {
|
}: IconAvatarProps) => {
|
||||||
@@ -20,6 +22,8 @@ export const IconAvatar = ({
|
|||||||
<div
|
<div
|
||||||
className={clx(
|
className={clx(
|
||||||
"shadow-borders-base flex size-7 items-center justify-center",
|
"shadow-borders-base flex size-7 items-center justify-center",
|
||||||
|
variant === "squared" && "rounded-md",
|
||||||
|
variant === "rounded" && "rounded-full",
|
||||||
"[&>div]:bg-ui-bg-field [&>div]:text-ui-fg-subtle [&>div]:flex [&>div]:size-6 [&>div]:items-center [&>div]:justify-center",
|
"[&>div]:bg-ui-bg-field [&>div]:text-ui-fg-subtle [&>div]:flex [&>div]:size-6 [&>div]:items-center [&>div]:justify-center",
|
||||||
{
|
{
|
||||||
"size-7 rounded-md [&>div]:size-6 [&>div]:rounded-[4px]":
|
"size-7 rounded-md [&>div]:size-6 [&>div]:rounded-[4px]":
|
||||||
|
|||||||
+13
-4
@@ -20,16 +20,20 @@ export const DataGridCellContainer = ({
|
|||||||
errors,
|
errors,
|
||||||
rowErrors,
|
rowErrors,
|
||||||
outerComponent,
|
outerComponent,
|
||||||
}: DataGridCellContainerProps & DataGridErrorRenderProps<any>) => {
|
isMultiLine,
|
||||||
|
}: DataGridCellContainerProps &
|
||||||
|
DataGridErrorRenderProps<any> & { isMultiLine?: boolean }) => {
|
||||||
const error = get(errors, field)
|
const error = get(errors, field)
|
||||||
const hasError = !!error
|
const hasError = !!error
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/container relative size-full">
|
<div className={clx("group/container relative h-full w-full")}>
|
||||||
<div
|
<div
|
||||||
className={clx(
|
className={clx(
|
||||||
"bg-ui-bg-base group/cell relative flex size-full items-center gap-x-2 px-4 py-2.5 outline-none",
|
"bg-ui-bg-base group/cell relative flex h-full w-full gap-x-2 px-4 py-2.5 outline-none",
|
||||||
{
|
{
|
||||||
|
"items-center": !isMultiLine,
|
||||||
|
"items-start": isMultiLine,
|
||||||
"bg-ui-tag-red-bg text-ui-tag-red-text":
|
"bg-ui-tag-red-bg text-ui-tag-red-text":
|
||||||
hasError && !isAnchor && !isSelected && !isDragSelected,
|
hasError && !isAnchor && !isSelected && !isDragSelected,
|
||||||
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
|
"ring-ui-bg-interactive ring-2 ring-inset": isAnchor,
|
||||||
@@ -54,7 +58,12 @@ export const DataGridCellContainer = ({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative z-[1] flex size-full items-center justify-center">
|
<div
|
||||||
|
className={clx("relative z-[1] flex h-full w-full", {
|
||||||
|
"items-center justify-center": !isMultiLine,
|
||||||
|
"items-start": isMultiLine,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
|
<RenderChildren isAnchor={isAnchor} placeholder={placeholder}>
|
||||||
{children}
|
{children}
|
||||||
</RenderChildren>
|
</RenderChildren>
|
||||||
|
|||||||
+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>
|
DataGridCellProps<TData, TValue>
|
||||||
> & {
|
> & {
|
||||||
color?: "muted" | "normal"
|
color?: "muted" | "normal"
|
||||||
|
isMultiLine?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataGridReadonlyCell = <TData, TValue = any>({
|
export const DataGridReadonlyCell = <TData, TValue = any>({
|
||||||
context,
|
context,
|
||||||
color = "muted",
|
color = "muted",
|
||||||
children,
|
children,
|
||||||
|
isMultiLine = false,
|
||||||
}: DataGridReadonlyCellProps<TData, TValue>) => {
|
}: DataGridReadonlyCellProps<TData, TValue>) => {
|
||||||
const { rowErrors } = useDataGridCellError({ context })
|
const { rowErrors } = useDataGridCellError({ context })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clx(
|
className={clx(
|
||||||
"txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none",
|
"txt-compact-small text-ui-fg-subtle flex w-full cursor-not-allowed justify-between overflow-hidden px-4 py-2.5 outline-none",
|
||||||
color === "muted" && "bg-ui-bg-subtle",
|
color === "muted" && "bg-ui-bg-subtle",
|
||||||
color === "normal" && "bg-ui-bg-base"
|
color === "normal" && "bg-ui-bg-base",
|
||||||
|
"h-full items-center"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex-1 truncate">{children}</div>
|
<div
|
||||||
|
className={clx("flex-1", {
|
||||||
|
truncate: !isMultiLine,
|
||||||
|
"whitespace-pre-wrap break-words": isMultiLine,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
<DataGridRowErrorIndicator rowErrors={rowErrors} />
|
<DataGridRowErrorIndicator rowErrors={rowErrors} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { VirtualItem, useVirtualizer } from "@tanstack/react-virtual"
|
import {
|
||||||
|
VirtualItem,
|
||||||
|
Virtualizer,
|
||||||
|
useVirtualizer,
|
||||||
|
} from "@tanstack/react-virtual"
|
||||||
import React, {
|
import React, {
|
||||||
CSSProperties,
|
CSSProperties,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
@@ -51,7 +55,7 @@ import { isCellMatch, isSpecialFocusKey } from "../utils"
|
|||||||
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
|
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
|
||||||
export interface DataGridRootProps<
|
export interface DataGridRootProps<
|
||||||
TData,
|
TData,
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues
|
||||||
> {
|
> {
|
||||||
data?: TData[]
|
data?: TData[]
|
||||||
columns: ColumnDef<TData>[]
|
columns: ColumnDef<TData>[]
|
||||||
@@ -60,6 +64,25 @@ export interface DataGridRootProps<
|
|||||||
onEditingChange?: (isEditing: boolean) => void
|
onEditingChange?: (isEditing: boolean) => void
|
||||||
disableInteractions?: boolean
|
disableInteractions?: boolean
|
||||||
multiColumnSelection?: boolean
|
multiColumnSelection?: boolean
|
||||||
|
showColumnsDropdown?: boolean
|
||||||
|
/**
|
||||||
|
* Custom content to render in the header, positioned between the column visibility
|
||||||
|
* controls and the error/shortcuts section.
|
||||||
|
*/
|
||||||
|
headerContent?: ReactNode
|
||||||
|
/**
|
||||||
|
* Lazy loading props - when totalRowCount is provided, the grid enters lazy loading mode.
|
||||||
|
* In this mode, the virtualizer will size based on totalRowCount and trigger onFetchMore
|
||||||
|
* when the user scrolls near the end of loaded data.
|
||||||
|
*/
|
||||||
|
/** Total count of rows for scroll sizing (enables lazy loading mode when provided) */
|
||||||
|
totalRowCount?: number
|
||||||
|
/** Called when more data should be fetched */
|
||||||
|
onFetchMore?: () => void
|
||||||
|
/** Whether more data is currently being fetched */
|
||||||
|
isFetchingMore?: boolean
|
||||||
|
/** Whether there is more data to fetch */
|
||||||
|
hasNextPage?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_HEIGHT = 40
|
const ROW_HEIGHT = 40
|
||||||
@@ -97,7 +120,7 @@ const getCommonPinningStyles = <TData,>(
|
|||||||
|
|
||||||
export const DataGridRoot = <
|
export const DataGridRoot = <
|
||||||
TData,
|
TData,
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues
|
||||||
>({
|
>({
|
||||||
data = [],
|
data = [],
|
||||||
columns,
|
columns,
|
||||||
@@ -106,7 +129,15 @@ export const DataGridRoot = <
|
|||||||
onEditingChange,
|
onEditingChange,
|
||||||
disableInteractions,
|
disableInteractions,
|
||||||
multiColumnSelection = false,
|
multiColumnSelection = false,
|
||||||
|
showColumnsDropdown = true,
|
||||||
|
totalRowCount,
|
||||||
|
onFetchMore,
|
||||||
|
isFetchingMore,
|
||||||
|
hasNextPage,
|
||||||
|
headerContent,
|
||||||
}: DataGridRootProps<TData, TFieldValues>) => {
|
}: DataGridRootProps<TData, TFieldValues>) => {
|
||||||
|
// TODO: remove once everything is lazy loaded
|
||||||
|
const isLazyMode = totalRowCount !== undefined
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const { redo, undo, execute } = useCommandHistory()
|
const { redo, undo, execute } = useCommandHistory()
|
||||||
@@ -163,10 +194,18 @@ export const DataGridRoot = <
|
|||||||
)
|
)
|
||||||
const visibleColumns = grid.getVisibleLeafColumns()
|
const visibleColumns = grid.getVisibleLeafColumns()
|
||||||
|
|
||||||
|
const effectiveRowCount = isLazyMode ? totalRowCount! : visibleRows.length
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: visibleRows.length,
|
count: effectiveRowCount,
|
||||||
estimateSize: () => ROW_HEIGHT,
|
estimateSize: () => ROW_HEIGHT,
|
||||||
getScrollElement: () => containerRef.current,
|
getScrollElement: () => containerRef.current,
|
||||||
|
// Measure actual row heights for dynamic sizing (disabled in Firefox due to measurement issues). Taken from Tanstack
|
||||||
|
measureElement:
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
navigator.userAgent.indexOf("Firefox") === -1
|
||||||
|
? (element) => element?.getBoundingClientRect().height
|
||||||
|
: undefined,
|
||||||
overscan: 5,
|
overscan: 5,
|
||||||
rangeExtractor: (range) => {
|
rangeExtractor: (range) => {
|
||||||
const toRender = new Set(
|
const toRender = new Set(
|
||||||
@@ -189,6 +228,76 @@ export const DataGridRoot = <
|
|||||||
})
|
})
|
||||||
const virtualRows = rowVirtualizer.getVirtualItems()
|
const virtualRows = rowVirtualizer.getVirtualItems()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy loading scroll detection.
|
||||||
|
* When the user scrolls near the end of loaded data, trigger onFetchMore.
|
||||||
|
* We use refs to get latest values in the scroll handler without re-attaching.
|
||||||
|
*/
|
||||||
|
const lazyLoadingRefs = useRef({
|
||||||
|
onFetchMore,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingMore,
|
||||||
|
loadedRowCount: visibleRows.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep refs updated
|
||||||
|
useEffect(() => {
|
||||||
|
lazyLoadingRefs.current = {
|
||||||
|
onFetchMore,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingMore,
|
||||||
|
loadedRowCount: visibleRows.length,
|
||||||
|
}
|
||||||
|
}, [onFetchMore, hasNextPage, isFetchingMore, visibleRows.length])
|
||||||
|
|
||||||
|
const hasData = visibleRows.length > 0
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const { onFetchMore, hasNextPage, isFetchingMore, loadedRowCount } =
|
||||||
|
lazyLoadingRefs.current
|
||||||
|
|
||||||
|
if (!onFetchMore || !hasNextPage || isFetchingMore) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollElement = containerRef.current
|
||||||
|
|
||||||
|
const { scrollTop, clientHeight } = scrollElement!
|
||||||
|
const loadedHeight = loadedRowCount * ROW_HEIGHT
|
||||||
|
const viewportBottom = scrollTop + clientHeight
|
||||||
|
const fetchThreshold = loadedHeight - ROW_HEIGHT * 10
|
||||||
|
|
||||||
|
if (viewportBottom >= fetchThreshold) {
|
||||||
|
onFetchMore()
|
||||||
|
}
|
||||||
|
}, [lazyLoadingRefs, containerRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLazyMode || !hasData) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const scrollElement: HTMLElement | null = containerRef.current
|
||||||
|
if (!scrollElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollElement.addEventListener("scroll", handleScroll)
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
const scrollElement = containerRef.current
|
||||||
|
scrollElement?.removeEventListener("scroll", handleScroll)
|
||||||
|
}
|
||||||
|
}, [isLazyMode, hasData])
|
||||||
|
|
||||||
const columnVirtualizer = useVirtualizer({
|
const columnVirtualizer = useVirtualizer({
|
||||||
count: visibleColumns.length,
|
count: visibleColumns.length,
|
||||||
estimateSize: (index) => visibleColumns[index].getSize(),
|
estimateSize: (index) => visibleColumns[index].getSize(),
|
||||||
@@ -552,6 +661,7 @@ export const DataGridRoot = <
|
|||||||
<DataGridContext.Provider value={values}>
|
<DataGridContext.Provider value={values}>
|
||||||
<div className="bg-ui-bg-subtle flex size-full flex-col">
|
<div className="bg-ui-bg-subtle flex size-full flex-col">
|
||||||
<DataGridHeader
|
<DataGridHeader
|
||||||
|
showColumnsDropdown={showColumnsDropdown}
|
||||||
columnOptions={columnOptions}
|
columnOptions={columnOptions}
|
||||||
isDisabled={isColumsDisabled}
|
isDisabled={isColumsDisabled}
|
||||||
onToggleColumn={handleToggleColumnVisibility}
|
onToggleColumn={handleToggleColumnVisibility}
|
||||||
@@ -560,6 +670,7 @@ export const DataGridRoot = <
|
|||||||
onResetColumns={handleResetColumns}
|
onResetColumns={handleResetColumns}
|
||||||
isHighlighted={isHighlighted}
|
isHighlighted={isHighlighted}
|
||||||
onHeaderInteractionChange={handleHeaderInteractionChange}
|
onHeaderInteractionChange={handleHeaderInteractionChange}
|
||||||
|
headerContent={headerContent}
|
||||||
/>
|
/>
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -650,6 +761,20 @@ export const DataGridRoot = <
|
|||||||
>
|
>
|
||||||
{virtualRows.map((virtualRow) => {
|
{virtualRows.map((virtualRow) => {
|
||||||
const row = visibleRows[virtualRow.index] as Row<TData>
|
const row = visibleRows[virtualRow.index] as Row<TData>
|
||||||
|
|
||||||
|
// In lazy mode, rows beyond loaded data show as skeleton
|
||||||
|
if (!row) {
|
||||||
|
return (
|
||||||
|
<DataGridRowSkeleton
|
||||||
|
key={`skeleton-${virtualRow.index}`}
|
||||||
|
virtualRow={virtualRow}
|
||||||
|
virtualColumns={virtualColumns}
|
||||||
|
virtualPaddingLeft={virtualPaddingLeft}
|
||||||
|
virtualPaddingRight={virtualPaddingRight}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
|
const rowIndex = flatRows.findIndex((r) => r.id === row.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -658,6 +783,7 @@ export const DataGridRoot = <
|
|||||||
row={row}
|
row={row}
|
||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
virtualRow={virtualRow}
|
virtualRow={virtualRow}
|
||||||
|
rowVirtualizer={rowVirtualizer}
|
||||||
flatColumns={flatColumns}
|
flatColumns={flatColumns}
|
||||||
virtualColumns={virtualColumns}
|
virtualColumns={virtualColumns}
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
@@ -680,12 +806,14 @@ export const DataGridRoot = <
|
|||||||
type DataGridHeaderProps = {
|
type DataGridHeaderProps = {
|
||||||
columnOptions: GridColumnOption[]
|
columnOptions: GridColumnOption[]
|
||||||
isDisabled: boolean
|
isDisabled: boolean
|
||||||
|
showColumnsDropdown: boolean
|
||||||
onToggleColumn: (index: number) => (value: boolean) => void
|
onToggleColumn: (index: number) => (value: boolean) => void
|
||||||
onResetColumns: () => void
|
onResetColumns: () => void
|
||||||
isHighlighted: boolean
|
isHighlighted: boolean
|
||||||
errorCount: number
|
errorCount: number
|
||||||
onToggleErrorHighlighting: () => void
|
onToggleErrorHighlighting: () => void
|
||||||
onHeaderInteractionChange: (isActive: boolean) => void
|
onHeaderInteractionChange: (isActive: boolean) => void
|
||||||
|
headerContent?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const DataGridHeader = ({
|
const DataGridHeader = ({
|
||||||
@@ -697,6 +825,8 @@ const DataGridHeader = ({
|
|||||||
errorCount,
|
errorCount,
|
||||||
onToggleErrorHighlighting,
|
onToggleErrorHighlighting,
|
||||||
onHeaderInteractionChange,
|
onHeaderInteractionChange,
|
||||||
|
showColumnsDropdown,
|
||||||
|
headerContent,
|
||||||
}: DataGridHeaderProps) => {
|
}: DataGridHeaderProps) => {
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [columnsOpen, setColumnsOpen] = useState(false)
|
const [columnsOpen, setColumnsOpen] = useState(false)
|
||||||
@@ -717,58 +847,61 @@ const DataGridHeader = ({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
|
<div className="bg-ui-bg-base flex items-center justify-between border-b p-4">
|
||||||
<div className="flex items-center gap-x-2">
|
{showColumnsDropdown && (
|
||||||
<DropdownMenu
|
<div className="flex items-center gap-x-2">
|
||||||
dir={direction}
|
<DropdownMenu
|
||||||
open={columnsOpen}
|
dir={direction}
|
||||||
onOpenChange={handleColumnsOpenChange}
|
open={columnsOpen}
|
||||||
>
|
onOpenChange={handleColumnsOpenChange}
|
||||||
<ConditionalTooltip
|
|
||||||
showTooltip={isDisabled}
|
|
||||||
content={t("dataGrid.columns.disabled")}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger asChild disabled={isDisabled}>
|
<ConditionalTooltip
|
||||||
<Button size="small" variant="secondary">
|
showTooltip={isDisabled}
|
||||||
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
|
content={t("dataGrid.columns.disabled")}
|
||||||
{t("dataGrid.columns.view")}
|
>
|
||||||
</Button>
|
<DropdownMenu.Trigger asChild disabled={isDisabled}>
|
||||||
</DropdownMenu.Trigger>
|
<Button size="small" variant="secondary">
|
||||||
</ConditionalTooltip>
|
{hasChanged ? <AdjustmentsDone /> : <Adjustments />}
|
||||||
<DropdownMenu.Content>
|
{t("dataGrid.columns.view")}
|
||||||
{columnOptions.map((column, index) => {
|
</Button>
|
||||||
const { checked, disabled, id, name } = column
|
</DropdownMenu.Trigger>
|
||||||
|
</ConditionalTooltip>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
{columnOptions.map((column, index) => {
|
||||||
|
const { checked, disabled, id, name } = column
|
||||||
|
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key={id}
|
key={id}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={onToggleColumn(index)}
|
onCheckedChange={onToggleColumn(index)}
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{hasChanged && (
|
{hasChanged && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onResetColumns}
|
onClick={onResetColumns}
|
||||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||||
data-id="reset-columns"
|
data-id="reset-columns"
|
||||||
>
|
>
|
||||||
{t("dataGrid.columns.resetToDefault")}
|
{t("dataGrid.columns.resetToDefault")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-x-2">
|
)}
|
||||||
|
{headerContent}
|
||||||
|
<div className="ml-auto flex items-center gap-x-2">
|
||||||
{errorCount > 0 && (
|
{errorCount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -832,11 +965,11 @@ const DataGridCell = <TData,>({
|
|||||||
data-row-index={rowIndex}
|
data-row-index={rowIndex}
|
||||||
data-column-index={columnIndex}
|
data-column-index={columnIndex}
|
||||||
className={clx(
|
className={clx(
|
||||||
"relative flex items-center border-b border-r p-0 outline-none"
|
"relative flex items-stretch border-b border-r p-0 outline-none"
|
||||||
)}
|
)}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="relative h-full w-full">
|
<div className="relative w-full">
|
||||||
{flexRender(cell.column.columnDef.cell, {
|
{flexRender(cell.column.columnDef.cell, {
|
||||||
...cell.getContext(),
|
...cell.getContext(),
|
||||||
columnIndex,
|
columnIndex,
|
||||||
@@ -861,10 +994,11 @@ const DataGridCell = <TData,>({
|
|||||||
type DataGridRowProps<TData> = {
|
type DataGridRowProps<TData> = {
|
||||||
row: Row<TData>
|
row: Row<TData>
|
||||||
rowIndex: number
|
rowIndex: number
|
||||||
virtualRow: VirtualItem<Element>
|
virtualRow: VirtualItem
|
||||||
|
rowVirtualizer: Virtualizer<HTMLDivElement, Element>
|
||||||
virtualPaddingLeft?: number
|
virtualPaddingLeft?: number
|
||||||
virtualPaddingRight?: number
|
virtualPaddingRight?: number
|
||||||
virtualColumns: VirtualItem<Element>[]
|
virtualColumns: VirtualItem[]
|
||||||
flatColumns: Column<TData, unknown>[]
|
flatColumns: Column<TData, unknown>[]
|
||||||
anchor: DataGridCoordinates | null
|
anchor: DataGridCoordinates | null
|
||||||
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
|
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
|
||||||
@@ -875,6 +1009,7 @@ const DataGridRow = <TData,>({
|
|||||||
row,
|
row,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
virtualRow,
|
virtualRow,
|
||||||
|
rowVirtualizer,
|
||||||
virtualPaddingLeft,
|
virtualPaddingLeft,
|
||||||
virtualPaddingRight,
|
virtualPaddingRight,
|
||||||
virtualColumns,
|
virtualColumns,
|
||||||
@@ -889,10 +1024,12 @@ const DataGridRow = <TData,>({
|
|||||||
<div
|
<div
|
||||||
role="row"
|
role="row"
|
||||||
aria-rowindex={virtualRow.index}
|
aria-rowindex={virtualRow.index}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={(node) => rowVirtualizer.measureElement(node)}
|
||||||
style={{
|
style={{
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
|
className="bg-ui-bg-subtle txt-compact-small absolute flex min-h-10 w-full"
|
||||||
>
|
>
|
||||||
{virtualPaddingLeft ? (
|
{virtualPaddingLeft ? (
|
||||||
<div
|
<div
|
||||||
@@ -943,3 +1080,77 @@ const DataGridRow = <TData,>({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton row component for lazy loading.
|
||||||
|
* Displays placeholder cells while data is being fetched.
|
||||||
|
*/
|
||||||
|
type DataGridRowSkeletonProps = {
|
||||||
|
virtualRow: VirtualItem
|
||||||
|
virtualPaddingLeft?: number
|
||||||
|
virtualPaddingRight?: number
|
||||||
|
virtualColumns: VirtualItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataGridRowSkeleton = ({
|
||||||
|
virtualRow,
|
||||||
|
virtualPaddingLeft,
|
||||||
|
virtualPaddingRight,
|
||||||
|
virtualColumns,
|
||||||
|
}: DataGridRowSkeletonProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
aria-rowindex={virtualRow.index}
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
className="bg-ui-bg-subtle txt-compact-small absolute flex h-10 w-full"
|
||||||
|
>
|
||||||
|
{virtualPaddingLeft ? (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
style={{ display: "flex", width: virtualPaddingLeft }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{virtualColumns.map((vc, index, array) => {
|
||||||
|
const previousVC = array[index - 1]
|
||||||
|
const elements: ReactNode[] = []
|
||||||
|
|
||||||
|
if (previousVC && vc.index !== previousVC.index + 1) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={`padding-${previousVC.index}-${vc.index}`}
|
||||||
|
role="presentation"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: `${vc.start - previousVC.end}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={`skeleton-cell-${vc.index}`}
|
||||||
|
role="gridcell"
|
||||||
|
style={{ width: vc.size }}
|
||||||
|
className="relative flex items-center border-b border-r p-0 outline-none"
|
||||||
|
>
|
||||||
|
<div className="flex h-full w-full items-center px-4">
|
||||||
|
<div className="bg-ui-bg-component h-4 w-3/4 animate-pulse rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return elements
|
||||||
|
})}
|
||||||
|
{virtualPaddingRight ? (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
style={{ display: "flex", width: virtualPaddingRight }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+7
-7
@@ -1,5 +1,5 @@
|
|||||||
import { clx } from "@medusajs/ui"
|
import { clx } from "@medusajs/ui"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { Controller, ControllerRenderProps } from "react-hook-form"
|
import { Controller, ControllerRenderProps } from "react-hook-form"
|
||||||
|
|
||||||
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
|
||||||
@@ -43,29 +43,29 @@ const Inner = ({
|
|||||||
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
|
const { ref: inputRef, onBlur: onInputBlur, onChange, ...input } = inputProps
|
||||||
|
|
||||||
const [localValue, setLocalValue] = useState(value)
|
const [localValue, setLocalValue] = useState(value)
|
||||||
|
const inputElRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalValue(value)
|
setLocalValue(value)
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
const combinedRefs = useCombinedRefs(inputRef, ref)
|
const combinedRefs = useCombinedRefs(inputRef, ref, inputElRef)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={clx(
|
className={clx(
|
||||||
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer items-center justify-center bg-transparent outline-none",
|
"txt-compact-small text-ui-fg-subtle flex size-full cursor-pointer bg-transparent outline-none",
|
||||||
"focus:cursor-text"
|
"focus:cursor-text",
|
||||||
|
"items-center justify-center"
|
||||||
)}
|
)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
value={localValue}
|
value={localValue ?? ""}
|
||||||
onChange={(e) => setLocalValue(e.target.value)}
|
onChange={(e) => setLocalValue(e.target.value)}
|
||||||
ref={combinedRefs}
|
ref={combinedRefs}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
onBlur()
|
onBlur()
|
||||||
onInputBlur()
|
onInputBlur()
|
||||||
|
|
||||||
// We propagate the change to the field only when the input is blurred
|
|
||||||
onChange(localValue, value)
|
onChange(localValue, value)
|
||||||
}}
|
}}
|
||||||
{...input}
|
{...input}
|
||||||
|
|||||||
+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 { DataGridBooleanCell } from "./data-grid-boolean-cell"
|
||||||
export { DataGridCurrencyCell } from "./data-grid-currency-cell"
|
export { DataGridCurrencyCell } from "./data-grid-currency-cell"
|
||||||
|
export { DataGridMultilineCell } from "./data-grid-multiline-cell"
|
||||||
export { DataGridNumberCell } from "./data-grid-number-cell"
|
export { DataGridNumberCell } from "./data-grid-number-cell"
|
||||||
export { DataGridReadonlyCell as DataGridReadOnlyCell } from "./data-grid-readonly-cell"
|
export { DataGridReadonlyCell as DataGridReadOnlyCell } from "./data-grid-readonly-cell"
|
||||||
export { DataGridRoot, type DataGridRootProps } from "./data-grid-root"
|
export { DataGridRoot, type DataGridRootProps } from "./data-grid-root"
|
||||||
export { DataGridSkeleton } from "./data-grid-skeleton"
|
export { DataGridSkeleton } from "./data-grid-skeleton"
|
||||||
export { DataGridTextCell } from "./data-grid-text-cell"
|
export { DataGridTextCell } from "./data-grid-text-cell"
|
||||||
|
export { DataGridExpandableTextCell } from "./data-grid-textarea-modal-cell"
|
||||||
@@ -3,11 +3,13 @@ import { FieldValues } from "react-hook-form"
|
|||||||
import {
|
import {
|
||||||
DataGridBooleanCell,
|
DataGridBooleanCell,
|
||||||
DataGridCurrencyCell,
|
DataGridCurrencyCell,
|
||||||
|
DataGridMultilineCell,
|
||||||
DataGridNumberCell,
|
DataGridNumberCell,
|
||||||
DataGridReadOnlyCell,
|
DataGridReadOnlyCell,
|
||||||
DataGridRoot,
|
DataGridRoot,
|
||||||
DataGridSkeleton,
|
DataGridSkeleton,
|
||||||
DataGridTextCell,
|
DataGridTextCell,
|
||||||
|
DataGridExpandableTextCell,
|
||||||
type DataGridRootProps,
|
type DataGridRootProps,
|
||||||
} from "./components"
|
} from "./components"
|
||||||
|
|
||||||
@@ -18,6 +20,11 @@ interface DataGridProps<TData, TFieldValues extends FieldValues = FieldValues>
|
|||||||
|
|
||||||
const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
|
const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
|
||||||
isLoading,
|
isLoading,
|
||||||
|
// Lazy loading props - passed through to DataGridRoot
|
||||||
|
totalRowCount,
|
||||||
|
onFetchMore,
|
||||||
|
isFetchingMore,
|
||||||
|
hasNextPage,
|
||||||
...props
|
...props
|
||||||
}: DataGridProps<TData, TFieldValues>) => {
|
}: DataGridProps<TData, TFieldValues>) => {
|
||||||
return isLoading ? (
|
return isLoading ? (
|
||||||
@@ -28,13 +35,21 @@ const _DataGrid = <TData, TFieldValues extends FieldValues = FieldValues>({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DataGridRoot {...props} />
|
<DataGridRoot
|
||||||
|
{...props}
|
||||||
|
totalRowCount={totalRowCount}
|
||||||
|
onFetchMore={onFetchMore}
|
||||||
|
isFetchingMore={isFetchingMore}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataGrid = Object.assign(_DataGrid, {
|
export const DataGrid = Object.assign(_DataGrid, {
|
||||||
BooleanCell: DataGridBooleanCell,
|
BooleanCell: DataGridBooleanCell,
|
||||||
TextCell: DataGridTextCell,
|
TextCell: DataGridTextCell,
|
||||||
|
MultilineCell: DataGridMultilineCell,
|
||||||
|
ExpandableTextCell: DataGridExpandableTextCell,
|
||||||
NumberCell: DataGridNumberCell,
|
NumberCell: DataGridNumberCell,
|
||||||
CurrencyCell: DataGridCurrencyCell,
|
CurrencyCell: DataGridCurrencyCell,
|
||||||
ReadonlyCell: DataGridReadOnlyCell,
|
ReadonlyCell: DataGridReadOnlyCell,
|
||||||
|
|||||||
+18
@@ -36,6 +36,18 @@ type DataGridHelperColumnsProps<TData, TFieldValues extends FieldValues> = {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
disableHiding?: boolean
|
disableHiding?: boolean
|
||||||
|
/**
|
||||||
|
* The size of the column in pixels.
|
||||||
|
*/
|
||||||
|
size?: number
|
||||||
|
/**
|
||||||
|
* The minimum size of the column in pixels.
|
||||||
|
*/
|
||||||
|
minSize?: number
|
||||||
|
/**
|
||||||
|
* The maximum size of the column in pixels.
|
||||||
|
*/
|
||||||
|
maxSize?: number
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
field: FieldFunction<TData, TFieldValues>
|
field: FieldFunction<TData, TFieldValues>
|
||||||
@@ -59,12 +71,18 @@ export function createDataGridHelper<
|
|||||||
disableHiding = false,
|
disableHiding = false,
|
||||||
field,
|
field,
|
||||||
type,
|
type,
|
||||||
|
size,
|
||||||
|
minSize,
|
||||||
|
maxSize,
|
||||||
}: DataGridHelperColumnsProps<TData, TFieldValues>) =>
|
}: DataGridHelperColumnsProps<TData, TFieldValues>) =>
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id,
|
id,
|
||||||
header,
|
header,
|
||||||
cell,
|
cell,
|
||||||
enableHiding: !disableHiding,
|
enableHiding: !disableHiding,
|
||||||
|
size,
|
||||||
|
minSize,
|
||||||
|
maxSize,
|
||||||
meta: {
|
meta: {
|
||||||
name,
|
name,
|
||||||
field,
|
field,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const useDataGridCell = <TData, TValue>({
|
|||||||
case "number":
|
case "number":
|
||||||
return numberCharacterRegex.test(key)
|
return numberCharacterRegex.test(key)
|
||||||
case "text":
|
case "text":
|
||||||
|
case "multiline-text":
|
||||||
return textCharacterRegex.test(key)
|
return textCharacterRegex.test(key)
|
||||||
default:
|
default:
|
||||||
// KeyboardEvents should not be forwareded to other types of cells
|
// KeyboardEvents should not be forwareded to other types of cells
|
||||||
@@ -180,6 +181,17 @@ export const useDataGridCell = <TData, TValue>({
|
|||||||
)?.set
|
)?.set
|
||||||
nativeInputValueSetter?.call(inputRef.current, e.key)
|
nativeInputValueSetter?.call(inputRef.current, e.key)
|
||||||
|
|
||||||
|
const event = new Event("input", { bubbles: true })
|
||||||
|
inputRef.current.dispatchEvent(event)
|
||||||
|
} else if (inputRef.current instanceof HTMLTextAreaElement) {
|
||||||
|
inputRef.current.value = ""
|
||||||
|
|
||||||
|
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLTextAreaElement.prototype,
|
||||||
|
"value"
|
||||||
|
)?.set
|
||||||
|
nativeTextAreaValueSetter?.call(inputRef.current, e.key)
|
||||||
|
|
||||||
// Trigger input event to notify react-hook-form
|
// Trigger input event to notify react-hook-form
|
||||||
const event = new Event("input", { bubbles: true })
|
const event = new Event("input", { bubbles: true })
|
||||||
inputRef.current.dispatchEvent(event)
|
inputRef.current.dispatchEvent(event)
|
||||||
@@ -202,7 +214,7 @@ export const useDataGridCell = <TData, TValue>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAnchor && !containerRef.current?.contains(document.activeElement)) {
|
if (isAnchor && !containerRef.current?.contains(document.activeElement)) {
|
||||||
containerRef.current?.focus()
|
containerRef.current?.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
}, [isAnchor])
|
}, [isAnchor])
|
||||||
|
|
||||||
|
|||||||
+1
@@ -228,6 +228,7 @@ export function convertArrayToPrimitive(
|
|||||||
case "boolean":
|
case "boolean":
|
||||||
return values.map(convertToBoolean)
|
return values.map(convertToBoolean)
|
||||||
case "text":
|
case "text":
|
||||||
|
case "multiline-text":
|
||||||
return values.map(covertToString)
|
return values.map(covertToString)
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported target type "${type}".`)
|
throw new Error(`Unsupported target type "${type}".`)
|
||||||
|
|||||||
+65
-7
@@ -211,7 +211,22 @@ export const useDataGridKeydownEvent = <
|
|||||||
[rangeEnd, matrix, getSelectionValues, setSelectionValues, execute]
|
[rangeEnd, matrix, getSelectionValues, setSelectionValues, execute]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSpaceKeyTextOrNumber = useCallback(
|
const handleSpaceKeyText = useCallback(
|
||||||
|
(anchor: DataGridCoordinates) => {
|
||||||
|
const field = matrix.getCellField(anchor)
|
||||||
|
const input = queryTool?.getInput(anchor)
|
||||||
|
|
||||||
|
if (!field || !input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createSnapshot(anchor)
|
||||||
|
input.focus()
|
||||||
|
},
|
||||||
|
[matrix, queryTool, createSnapshot]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSpaceKeyNumber = useCallback(
|
||||||
(anchor: DataGridCoordinates) => {
|
(anchor: DataGridCoordinates) => {
|
||||||
const field = matrix.getCellField(anchor)
|
const field = matrix.getCellField(anchor)
|
||||||
const input = queryTool?.getInput(anchor)
|
const input = queryTool?.getInput(anchor)
|
||||||
@@ -303,9 +318,12 @@ export const useDataGridKeydownEvent = <
|
|||||||
case "togglable-number":
|
case "togglable-number":
|
||||||
handleSpaceKeyTogglableNumber(anchor)
|
handleSpaceKeyTogglableNumber(anchor)
|
||||||
break
|
break
|
||||||
case "number":
|
|
||||||
case "text":
|
case "text":
|
||||||
handleSpaceKeyTextOrNumber(anchor)
|
case "multiline-text":
|
||||||
|
handleSpaceKeyText(anchor)
|
||||||
|
break
|
||||||
|
case "number":
|
||||||
|
handleSpaceKeyNumber(anchor)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -314,7 +332,8 @@ export const useDataGridKeydownEvent = <
|
|||||||
isEditing,
|
isEditing,
|
||||||
matrix,
|
matrix,
|
||||||
handleSpaceKeyBoolean,
|
handleSpaceKeyBoolean,
|
||||||
handleSpaceKeyTextOrNumber,
|
handleSpaceKeyText,
|
||||||
|
handleSpaceKeyNumber,
|
||||||
handleSpaceKeyTogglableNumber,
|
handleSpaceKeyTogglableNumber,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -384,6 +403,30 @@ export const useDataGridKeydownEvent = <
|
|||||||
[handleMoveOnEnter, handleEditOnEnter, isEditing]
|
[handleMoveOnEnter, handleEditOnEnter, isEditing]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the enter key for multiline-text cells.
|
||||||
|
*
|
||||||
|
* The behavior is as follows:
|
||||||
|
* - If Shift+Enter is pressed while editing, allow the newline (don't prevent default).
|
||||||
|
* - If Enter is pressed while editing (without Shift), move to the next cell.
|
||||||
|
* - If the cell is currently not being edited, start editing the cell.
|
||||||
|
*/
|
||||||
|
const handleEnterKeyMultilineText = useCallback(
|
||||||
|
(e: KeyboardEvent, anchor: DataGridCoordinates) => {
|
||||||
|
if (isEditing) {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMoveOnEnter(e, anchor)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEditOnEnter(anchor)
|
||||||
|
},
|
||||||
|
[handleMoveOnEnter, handleEditOnEnter, isEditing]
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the enter key for boolean cells.
|
* Handles the enter key for boolean cells.
|
||||||
*
|
*
|
||||||
@@ -432,11 +475,18 @@ export const useDataGridKeydownEvent = <
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const type = matrix.getCellType(anchor)
|
const type = matrix.getCellType(anchor)
|
||||||
|
|
||||||
|
if (type === "multiline-text" && isEditing && e.shiftKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case "multiline-text":
|
||||||
|
handleEnterKeyMultilineText(e, anchor)
|
||||||
|
break
|
||||||
case "togglable-number":
|
case "togglable-number":
|
||||||
case "text":
|
case "text":
|
||||||
case "number":
|
case "number":
|
||||||
@@ -448,7 +498,14 @@ export const useDataGridKeydownEvent = <
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean]
|
[
|
||||||
|
anchor,
|
||||||
|
matrix,
|
||||||
|
isEditing,
|
||||||
|
handleEnterKeyTextOrNumber,
|
||||||
|
handleEnterKeyBoolean,
|
||||||
|
handleEnterKeyMultilineText,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDeleteKeyTogglableNumber = useCallback(
|
const handleDeleteKeyTogglableNumber = useCallback(
|
||||||
@@ -526,6 +583,7 @@ export const useDataGridKeydownEvent = <
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "text":
|
case "text":
|
||||||
|
case "multiline-text":
|
||||||
case "number":
|
case "number":
|
||||||
handleDeleteKeyTextOrNumber(anchor, rangeEnd)
|
handleDeleteKeyTextOrNumber(anchor, rangeEnd)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
|
|
||||||
export type DataGridColumnType =
|
export type DataGridColumnType =
|
||||||
| "text"
|
| "text"
|
||||||
|
| "multiline-text"
|
||||||
| "number"
|
| "number"
|
||||||
| "boolean"
|
| "boolean"
|
||||||
| "togglable-number"
|
| "togglable-number"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useExtension } from "../../../providers/extension-provider"
|
|||||||
import { INavItem, NavItem } from "../nav-item"
|
import { INavItem, NavItem } from "../nav-item"
|
||||||
import { Shell } from "../shell"
|
import { Shell } from "../shell"
|
||||||
import { UserMenu } from "../user-menu"
|
import { UserMenu } from "../user-menu"
|
||||||
|
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
export const SettingsLayout = () => {
|
export const SettingsLayout = () => {
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +20,7 @@ export const SettingsLayout = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const useSettingRoutes = (): INavItem[] => {
|
const useSettingRoutes = (): INavItem[] => {
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
@@ -63,8 +65,16 @@ const useSettingRoutes = (): INavItem[] => {
|
|||||||
label: t("stockLocations.domain"),
|
label: t("stockLocations.domain"),
|
||||||
to: "/settings/locations",
|
to: "/settings/locations",
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t("translations.domain"),
|
||||||
|
to: "/settings/translations",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
[t]
|
[t, isTranslationsEnabled]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -8,7 +8,7 @@ import { RouteModalProvider } from "../route-modal-provider/route-provider"
|
|||||||
import { StackedModalProvider } from "../stacked-modal-provider"
|
import { StackedModalProvider } from "../stacked-modal-provider"
|
||||||
|
|
||||||
type RouteFocusModalProps = PropsWithChildren<{
|
type RouteFocusModalProps = PropsWithChildren<{
|
||||||
prev?: string | Partial<Path>
|
prev?: string | Partial<Path> | number
|
||||||
}>
|
}>
|
||||||
|
|
||||||
const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
|
const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
|
||||||
@@ -16,7 +16,8 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [stackedModalOpen, onStackedModalOpen] = useState(false)
|
const [stackedModalOpen, onStackedModalOpen] = useState(false)
|
||||||
|
|
||||||
const to = useStateAwareTo(prev)
|
const to: string | Partial<Path> | number =
|
||||||
|
typeof prev === "number" ? prev : useStateAwareTo(prev)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the modal when the component mounts. This
|
* Open the modal when the component mounts. This
|
||||||
@@ -34,7 +35,11 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
|
|||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
document.body.style.pointerEvents = "auto"
|
document.body.style.pointerEvents = "auto"
|
||||||
navigate(to, { replace: true })
|
if (typeof to === "number") {
|
||||||
|
navigate(to)
|
||||||
|
} else {
|
||||||
|
navigate(to, { replace: true })
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -24,7 +24,12 @@ export const RouteModalForm = <TFieldValues extends FieldValues = any>({
|
|||||||
} = form
|
} = form
|
||||||
|
|
||||||
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
|
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
|
||||||
const { isSubmitSuccessful } = nextLocation.state || {}
|
// Check both nextLocation and currentLocation state for successful submission
|
||||||
|
// This handles browser history navigation (-1) where we set state on current location
|
||||||
|
const { isSubmitSuccessful: nextIsSuccessful } = nextLocation.state || {}
|
||||||
|
const { isSubmitSuccessful: currentIsSuccessful } =
|
||||||
|
currentLocation.state || {}
|
||||||
|
const isSubmitSuccessful = nextIsSuccessful || currentIsSuccessful
|
||||||
|
|
||||||
if (isSubmitSuccessful) {
|
if (isSubmitSuccessful) {
|
||||||
onClose?.(true)
|
onClose?.(true)
|
||||||
|
|||||||
+16
-4
@@ -1,9 +1,9 @@
|
|||||||
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
|
import { PropsWithChildren, useCallback, useMemo, useState } from "react"
|
||||||
import { Path, useNavigate } from "react-router-dom"
|
import { Path, useLocation, useNavigate } from "react-router-dom"
|
||||||
import { RouteModalProviderContext } from "./route-modal-context"
|
import { RouteModalProviderContext } from "./route-modal-context"
|
||||||
|
|
||||||
type RouteModalProviderProps = PropsWithChildren<{
|
type RouteModalProviderProps = PropsWithChildren<{
|
||||||
prev: string | Partial<Path>
|
prev: string | Partial<Path> | number
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export const RouteModalProvider = ({
|
export const RouteModalProvider = ({
|
||||||
@@ -11,15 +11,27 @@ export const RouteModalProvider = ({
|
|||||||
children,
|
children,
|
||||||
}: RouteModalProviderProps) => {
|
}: RouteModalProviderProps) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
const [closeOnEscape, setCloseOnEscape] = useState(true)
|
const [closeOnEscape, setCloseOnEscape] = useState(true)
|
||||||
|
|
||||||
const handleSuccess = useCallback(
|
const handleSuccess = useCallback(
|
||||||
(path?: string) => {
|
(path?: string) => {
|
||||||
const to = path || prev
|
const to = path || prev
|
||||||
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
|
if (typeof to === "number") {
|
||||||
|
// Replace current location with success state, then navigate back
|
||||||
|
navigate(location.pathname + location.search, {
|
||||||
|
replace: true,
|
||||||
|
state: { ...location.state, isSubmitSuccessful: true },
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(to)
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
navigate(to, { replace: true, state: { isSubmitSuccessful: true } })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[navigate, prev]
|
[navigate, prev, location]
|
||||||
)
|
)
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
|
|||||||
@@ -1840,6 +1840,29 @@ export function getRouteMap({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "translations",
|
||||||
|
errorElement: <ErrorBoundary />,
|
||||||
|
handle: {
|
||||||
|
breadcrumb: () => t("translations.domain"),
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/translations/translation-list"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "edit",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../routes/translations/translations-edit"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "add-locales",
|
||||||
|
lazy: () => import("../../routes/translations/add-locales"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
...(settingsRoutes?.[0]?.children || []),
|
...(settingsRoutes?.[0]?.children || []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { FetchError } from "@medusajs/js-sdk"
|
import { FetchError } from "@medusajs/js-sdk"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
|
InfiniteData,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -11,6 +13,7 @@ import { sdk } from "../../lib/client"
|
|||||||
import { queryClient } from "../../lib/query-client"
|
import { queryClient } from "../../lib/query-client"
|
||||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
import { productsQueryKeys } from "./products"
|
import { productsQueryKeys } from "./products"
|
||||||
|
import { useInfiniteList } from "../use-infinite-list"
|
||||||
|
|
||||||
const CATEGORIES_QUERY_KEY = "categories" as const
|
const CATEGORIES_QUERY_KEY = "categories" as const
|
||||||
export const categoriesQueryKeys = queryKeysFactory(CATEGORIES_QUERY_KEY)
|
export const categoriesQueryKeys = queryKeysFactory(CATEGORIES_QUERY_KEY)
|
||||||
@@ -58,6 +61,35 @@ export const useProductCategories = (
|
|||||||
return { ...data, ...rest }
|
return { ...data, ...rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useInfiniteCategories = (
|
||||||
|
query?: Omit<HttpTypes.AdminProductCategoryListParams, "offset" | "limit"> & {
|
||||||
|
limit?: number
|
||||||
|
},
|
||||||
|
options?: Omit<
|
||||||
|
UseInfiniteQueryOptions<
|
||||||
|
HttpTypes.AdminProductCategoryListResponse,
|
||||||
|
FetchError,
|
||||||
|
InfiniteData<HttpTypes.AdminProductCategoryListResponse, number>,
|
||||||
|
HttpTypes.AdminProductCategoryListResponse,
|
||||||
|
QueryKey,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useInfiniteList<
|
||||||
|
HttpTypes.AdminProductCategoryListResponse,
|
||||||
|
HttpTypes.AdminProductCategoryListParams,
|
||||||
|
FetchError,
|
||||||
|
QueryKey
|
||||||
|
>({
|
||||||
|
queryKey: (params) => categoriesQueryKeys.list(params),
|
||||||
|
queryFn: (params) => sdk.admin.productCategory.list(params),
|
||||||
|
query,
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const useCreateProductCategory = (
|
export const useCreateProductCategory = (
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
HttpTypes.AdminProductCategoryResponse,
|
HttpTypes.AdminProductCategoryResponse,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { FetchError } from "@medusajs/js-sdk"
|
import { FetchError } from "@medusajs/js-sdk"
|
||||||
import { FindParams, HttpTypes, PaginatedResponse } from "@medusajs/types"
|
import { FindParams, HttpTypes, PaginatedResponse } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
|
InfiniteData,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -11,6 +13,7 @@ import { sdk } from "../../lib/client"
|
|||||||
import { queryClient } from "../../lib/query-client"
|
import { queryClient } from "../../lib/query-client"
|
||||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
import { productsQueryKeys } from "./products"
|
import { productsQueryKeys } from "./products"
|
||||||
|
import { useInfiniteList } from "../use-infinite-list"
|
||||||
|
|
||||||
const COLLECTION_QUERY_KEY = "collections" as const
|
const COLLECTION_QUERY_KEY = "collections" as const
|
||||||
export const collectionsQueryKeys = queryKeysFactory(COLLECTION_QUERY_KEY)
|
export const collectionsQueryKeys = queryKeysFactory(COLLECTION_QUERY_KEY)
|
||||||
@@ -57,6 +60,34 @@ export const useCollections = (
|
|||||||
return { ...data, ...rest }
|
return { ...data, ...rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useInfiniteCollections = (
|
||||||
|
query?: Omit<HttpTypes.AdminCollectionListParams, "offset" | "limit"> & {
|
||||||
|
limit?: number
|
||||||
|
},
|
||||||
|
options?: Omit<
|
||||||
|
UseInfiniteQueryOptions<
|
||||||
|
HttpTypes.AdminCollectionListResponse,
|
||||||
|
FetchError,
|
||||||
|
InfiniteData<HttpTypes.AdminCollectionListResponse, number>,
|
||||||
|
HttpTypes.AdminCollectionListResponse,
|
||||||
|
QueryKey,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useInfiniteList<
|
||||||
|
HttpTypes.AdminCollectionListResponse,
|
||||||
|
HttpTypes.AdminCollectionListParams,
|
||||||
|
FetchError,
|
||||||
|
QueryKey
|
||||||
|
>({
|
||||||
|
queryKey: (params) => collectionsQueryKeys.list(params),
|
||||||
|
queryFn: (params) => sdk.admin.productCollection.list(params),
|
||||||
|
query,
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
}
|
||||||
export const useUpdateCollection = (
|
export const useUpdateCollection = (
|
||||||
id: string,
|
id: string,
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export * from "./store"
|
|||||||
export * from "./tags"
|
export * from "./tags"
|
||||||
export * from "./tax-rates"
|
export * from "./tax-rates"
|
||||||
export * from "./tax-regions"
|
export * from "./tax-regions"
|
||||||
|
export * from "./translations"
|
||||||
export * from "./users"
|
export * from "./users"
|
||||||
export * from "./views"
|
export * from "./views"
|
||||||
export * from "./workflow-executions"
|
export * from "./workflow-executions"
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { FetchError } from "@medusajs/js-sdk"
|
import { FetchError } from "@medusajs/js-sdk"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
|
InfiniteData,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
import { sdk } from "../../lib/client"
|
import { sdk } from "../../lib/client"
|
||||||
import { queryClient } from "../../lib/query-client"
|
import { queryClient } from "../../lib/query-client"
|
||||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
|
import { useInfiniteList } from "../use-infinite-list"
|
||||||
|
|
||||||
const PRODUCT_TYPES_QUERY_KEY = "product_types" as const
|
const PRODUCT_TYPES_QUERY_KEY = "product_types" as const
|
||||||
export const productTypesQueryKeys = queryKeysFactory(PRODUCT_TYPES_QUERY_KEY)
|
export const productTypesQueryKeys = queryKeysFactory(PRODUCT_TYPES_QUERY_KEY)
|
||||||
@@ -57,6 +60,35 @@ export const useProductTypes = (
|
|||||||
return { ...data, ...rest }
|
return { ...data, ...rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useInfiniteProductTypes = (
|
||||||
|
query?: Omit<HttpTypes.AdminProductTypeListParams, "offset" | "limit"> & {
|
||||||
|
limit?: number
|
||||||
|
},
|
||||||
|
options?: Omit<
|
||||||
|
UseInfiniteQueryOptions<
|
||||||
|
HttpTypes.AdminProductTypeListResponse,
|
||||||
|
FetchError,
|
||||||
|
InfiniteData<HttpTypes.AdminProductTypeListResponse, number>,
|
||||||
|
HttpTypes.AdminProductTypeListResponse,
|
||||||
|
QueryKey,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useInfiniteList<
|
||||||
|
HttpTypes.AdminProductTypeListResponse,
|
||||||
|
HttpTypes.AdminProductTypeListParams,
|
||||||
|
FetchError,
|
||||||
|
QueryKey
|
||||||
|
>({
|
||||||
|
queryKey: (params) => productTypesQueryKeys.list(params),
|
||||||
|
queryFn: (params) => sdk.admin.productType.list(params),
|
||||||
|
query,
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const useCreateProductType = (
|
export const useCreateProductType = (
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
HttpTypes.AdminProductTypeResponse,
|
HttpTypes.AdminProductTypeResponse,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
|
useQuery,
|
||||||
|
UseQueryOptions,
|
||||||
|
} from "@tanstack/react-query"
|
||||||
|
import { InfiniteData } from "@tanstack/query-core"
|
||||||
import { sdk } from "../../lib/client"
|
import { sdk } from "../../lib/client"
|
||||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
import { FetchError } from "@medusajs/js-sdk"
|
import { FetchError } from "@medusajs/js-sdk"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { useInfiniteList } from "../use-infinite-list"
|
||||||
|
|
||||||
const PRODUCT_VARIANT_QUERY_KEY = "product_variant" as const
|
const PRODUCT_VARIANT_QUERY_KEY = "product_variant" as const
|
||||||
export const productVariantQueryKeys = queryKeysFactory(
|
export const productVariantQueryKeys = queryKeysFactory(
|
||||||
@@ -9,9 +17,14 @@ export const productVariantQueryKeys = queryKeysFactory(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const useVariants = (
|
export const useVariants = (
|
||||||
query?: Record<string, any>,
|
query?: HttpTypes.AdminProductVariantParams,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
UseQueryOptions<any, FetchError, any, QueryKey>,
|
UseQueryOptions<
|
||||||
|
HttpTypes.AdminProductVariantListResponse,
|
||||||
|
FetchError,
|
||||||
|
HttpTypes.AdminProductVariantListResponse,
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
"queryFn" | "queryKey"
|
"queryFn" | "queryKey"
|
||||||
>
|
>
|
||||||
) => {
|
) => {
|
||||||
@@ -23,3 +36,34 @@ export const useVariants = (
|
|||||||
|
|
||||||
return { ...data, ...rest }
|
return { ...data, ...rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useInfiniteVariants = (
|
||||||
|
query?: Omit<HttpTypes.AdminProductVariantParams, "offset" | "limit"> & {
|
||||||
|
limit?: number
|
||||||
|
},
|
||||||
|
options?: Omit<
|
||||||
|
UseInfiniteQueryOptions<
|
||||||
|
HttpTypes.AdminProductVariantListResponse,
|
||||||
|
FetchError,
|
||||||
|
InfiniteData<HttpTypes.AdminProductVariantListResponse, number>,
|
||||||
|
HttpTypes.AdminProductVariantListResponse,
|
||||||
|
QueryKey,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useInfiniteList<
|
||||||
|
HttpTypes.AdminProductVariantListResponse,
|
||||||
|
HttpTypes.AdminProductVariantParams,
|
||||||
|
FetchError,
|
||||||
|
QueryKey
|
||||||
|
>({
|
||||||
|
queryKey: (params) => productVariantQueryKeys.list(params),
|
||||||
|
queryFn: async (params) => {
|
||||||
|
return await sdk.admin.productVariant.list(params)
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,15 +2,18 @@ import { FetchError } from "@medusajs/js-sdk"
|
|||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
useMutation,
|
useMutation,
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
useQuery,
|
useQuery,
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
} from "@tanstack/react-query"
|
} from "@tanstack/react-query"
|
||||||
|
import { InfiniteData } from "@tanstack/query-core"
|
||||||
import { sdk } from "../../lib/client"
|
import { sdk } from "../../lib/client"
|
||||||
import { queryClient } from "../../lib/query-client"
|
import { queryClient } from "../../lib/query-client"
|
||||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
import { inventoryItemsQueryKeys } from "./inventory.tsx"
|
import { inventoryItemsQueryKeys } from "./inventory.tsx"
|
||||||
|
import { useInfiniteList } from "../use-infinite-list.tsx"
|
||||||
|
|
||||||
const PRODUCTS_QUERY_KEY = "products" as const
|
const PRODUCTS_QUERY_KEY = "products" as const
|
||||||
export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY)
|
export const productsQueryKeys = queryKeysFactory(PRODUCTS_QUERY_KEY)
|
||||||
@@ -310,6 +313,32 @@ export const useProducts = (
|
|||||||
return { ...data, ...rest }
|
return { ...data, ...rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useInfiniteProducts = (
|
||||||
|
query?: HttpTypes.AdminProductListParams,
|
||||||
|
options?: Omit<
|
||||||
|
UseInfiniteQueryOptions<
|
||||||
|
HttpTypes.AdminProductListResponse,
|
||||||
|
FetchError,
|
||||||
|
InfiniteData<HttpTypes.AdminProductListResponse, number>,
|
||||||
|
HttpTypes.AdminProductListResponse,
|
||||||
|
QueryKey,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useInfiniteList<
|
||||||
|
HttpTypes.AdminProductListResponse,
|
||||||
|
HttpTypes.AdminProductListParams,
|
||||||
|
FetchError,
|
||||||
|
QueryKey
|
||||||
|
>({
|
||||||
|
queryKey: (params) => productsQueryKeys.list(params),
|
||||||
|
queryFn: (params) => sdk.admin.product.list(params),
|
||||||
|
query,
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
}
|
||||||
export const useCreateProduct = (
|
export const useCreateProduct = (
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
HttpTypes.AdminProductResponse,
|
HttpTypes.AdminProductResponse,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { FetchError } from "@medusajs/js-sdk"
|
import { FetchError } from "@medusajs/js-sdk"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
|
InfiniteData,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
import { sdk } from "../../lib/client"
|
import { sdk } from "../../lib/client"
|
||||||
import { queryClient } from "../../lib/query-client"
|
import { queryClient } from "../../lib/query-client"
|
||||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
|
import { useInfiniteList } from "../use-infinite-list"
|
||||||
|
|
||||||
const TAGS_QUERY_KEY = "tags" as const
|
const TAGS_QUERY_KEY = "tags" as const
|
||||||
export const productTagsQueryKeys = queryKeysFactory(TAGS_QUERY_KEY)
|
export const productTagsQueryKeys = queryKeysFactory(TAGS_QUERY_KEY)
|
||||||
@@ -57,6 +60,35 @@ export const useProductTags = (
|
|||||||
return { ...data, ...rest }
|
return { ...data, ...rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useInfiniteProductTags = (
|
||||||
|
query?: Omit<HttpTypes.AdminProductTagListParams, "offset" | "limit"> & {
|
||||||
|
limit?: number
|
||||||
|
},
|
||||||
|
options?: Omit<
|
||||||
|
UseInfiniteQueryOptions<
|
||||||
|
HttpTypes.AdminProductTagListResponse,
|
||||||
|
FetchError,
|
||||||
|
InfiniteData<HttpTypes.AdminProductTagListResponse, number>,
|
||||||
|
HttpTypes.AdminProductTagListResponse,
|
||||||
|
QueryKey,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useInfiniteList<
|
||||||
|
HttpTypes.AdminProductTagListResponse,
|
||||||
|
HttpTypes.AdminProductTagListParams,
|
||||||
|
FetchError,
|
||||||
|
QueryKey
|
||||||
|
>({
|
||||||
|
queryKey: (params) => productTagsQueryKeys.list(params),
|
||||||
|
queryFn: (params) => sdk.admin.productTag.list(params),
|
||||||
|
query,
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const useCreateProductTag = (
|
export const useCreateProductTag = (
|
||||||
query?: HttpTypes.AdminProductTagParams,
|
query?: HttpTypes.AdminProductTagParams,
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { FetchError } from "@medusajs/js-sdk"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
|
UseInfiniteQueryResult,
|
||||||
|
useMutation,
|
||||||
|
UseMutationOptions,
|
||||||
|
useQuery,
|
||||||
|
UseQueryOptions,
|
||||||
|
} from "@tanstack/react-query"
|
||||||
|
import { sdk } from "../../lib/client"
|
||||||
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
|
import { queryClient } from "../../lib/query-client"
|
||||||
|
import { productsQueryKeys, useInfiniteProducts } from "./products"
|
||||||
|
import {
|
||||||
|
productVariantQueryKeys,
|
||||||
|
useInfiniteVariants,
|
||||||
|
} from "./product-variants"
|
||||||
|
import { categoriesQueryKeys, useInfiniteCategories } from "./categories"
|
||||||
|
import { collectionsQueryKeys, useInfiniteCollections } from "./collections"
|
||||||
|
import { productTagsQueryKeys, useInfiniteProductTags } from "./tags"
|
||||||
|
import { productTypesQueryKeys, useInfiniteProductTypes } from "./product-types"
|
||||||
|
|
||||||
|
const TRANSLATIONS_QUERY_KEY = "translations" as const
|
||||||
|
export const translationsQueryKeys = queryKeysFactory(TRANSLATIONS_QUERY_KEY)
|
||||||
|
|
||||||
|
const TRANSLATION_SETTINGS_QUERY_KEY = "translation_settings" as const
|
||||||
|
export const translationSettingsQueryKeys = queryKeysFactory(
|
||||||
|
TRANSLATION_SETTINGS_QUERY_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
const TRANSLATION_STATISTICS_QUERY_KEY = "translation_statistics" as const
|
||||||
|
export const translationStatisticsQueryKeys = queryKeysFactory(
|
||||||
|
TRANSLATION_STATISTICS_QUERY_KEY
|
||||||
|
)
|
||||||
|
|
||||||
|
export const useReferenceTranslations = (
|
||||||
|
reference: string,
|
||||||
|
translatableFields: string[],
|
||||||
|
referenceId?: string | string[],
|
||||||
|
options?: Omit<
|
||||||
|
UseInfiniteQueryOptions<any, FetchError, any, any, QueryKey, number>,
|
||||||
|
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const referenceHookMap = new Map<
|
||||||
|
string,
|
||||||
|
() => Omit<UseInfiniteQueryResult<any, FetchError>, "data"> & {
|
||||||
|
data: {
|
||||||
|
translations: HttpTypes.AdminTranslation[]
|
||||||
|
references: (Record<string, any> & { id: string })[]
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>([
|
||||||
|
[
|
||||||
|
"product",
|
||||||
|
() => {
|
||||||
|
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||||
|
|
||||||
|
const { data, ...rest } = useInfiniteProducts(
|
||||||
|
{ fields, id: referenceId ?? [] },
|
||||||
|
options
|
||||||
|
)
|
||||||
|
const products = data?.pages.flatMap((page) => page.products) ?? []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: {
|
||||||
|
translations:
|
||||||
|
products?.flatMap((product) => product.translations ?? []) ?? [],
|
||||||
|
references: products ?? [],
|
||||||
|
count: data?.pages[0]?.count ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"product_variant",
|
||||||
|
() => {
|
||||||
|
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||||
|
|
||||||
|
const { data, ...rest } = useInfiniteVariants(
|
||||||
|
{ id: referenceId ?? [], fields },
|
||||||
|
options
|
||||||
|
)
|
||||||
|
const variants = data?.pages.flatMap((page) => page.variants) ?? []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: {
|
||||||
|
translations:
|
||||||
|
variants?.flatMap((variant) => variant.translations ?? []) ?? [],
|
||||||
|
references: variants ?? [],
|
||||||
|
translatableFields,
|
||||||
|
count: data?.pages[0]?.count ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"product_category",
|
||||||
|
() => {
|
||||||
|
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||||
|
|
||||||
|
const { data, ...rest } = useInfiniteCategories(
|
||||||
|
{ id: referenceId ?? [], fields },
|
||||||
|
options
|
||||||
|
)
|
||||||
|
const categories =
|
||||||
|
data?.pages.flatMap((page) => page.product_categories) ?? []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: {
|
||||||
|
translations:
|
||||||
|
categories?.flatMap((category) => category.translations ?? []) ??
|
||||||
|
[],
|
||||||
|
references: categories ?? [],
|
||||||
|
translatableFields,
|
||||||
|
count: data?.pages[0]?.count ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"product_collection",
|
||||||
|
() => {
|
||||||
|
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||||
|
|
||||||
|
const { data, ...rest } = useInfiniteCollections(
|
||||||
|
{ id: referenceId ?? [], fields },
|
||||||
|
options
|
||||||
|
)
|
||||||
|
const collections =
|
||||||
|
data?.pages.flatMap((page) => page.collections) ?? []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: {
|
||||||
|
translations:
|
||||||
|
collections?.flatMap(
|
||||||
|
(collection) => collection.translations ?? []
|
||||||
|
) ?? [],
|
||||||
|
references: collections ?? [],
|
||||||
|
translatableFields,
|
||||||
|
count: data?.pages[0]?.count ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"product_type",
|
||||||
|
() => {
|
||||||
|
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||||
|
|
||||||
|
const { data, ...rest } = useInfiniteProductTypes(
|
||||||
|
{ id: referenceId ?? [], fields },
|
||||||
|
options
|
||||||
|
)
|
||||||
|
const product_types =
|
||||||
|
data?.pages.flatMap((page) => page.product_types) ?? []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: {
|
||||||
|
translations:
|
||||||
|
product_types?.flatMap((type) => type.translations ?? []) ?? [],
|
||||||
|
references: product_types ?? [],
|
||||||
|
count: data?.pages[0]?.count ?? 0,
|
||||||
|
translatableFields,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"product_tag",
|
||||||
|
() => {
|
||||||
|
const fields = translatableFields.concat(["translations.*"]).join(",")
|
||||||
|
|
||||||
|
const { data, ...rest } = useInfiniteProductTags(
|
||||||
|
{ id: referenceId ?? [], fields },
|
||||||
|
options
|
||||||
|
)
|
||||||
|
const product_tags =
|
||||||
|
data?.pages.flatMap((page) => page.product_tags) ?? []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: {
|
||||||
|
translations:
|
||||||
|
product_tags?.flatMap((tag) => tag.translations ?? []) ?? [],
|
||||||
|
references: product_tags ?? [],
|
||||||
|
translatableFields,
|
||||||
|
count: data?.pages[0]?.count ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// TODO: product option and option values
|
||||||
|
])
|
||||||
|
const referenceHook = referenceHookMap.get(reference)
|
||||||
|
if (!referenceHook) {
|
||||||
|
throw new Error(`No hook found for reference type: ${reference}`)
|
||||||
|
}
|
||||||
|
const { data, ...rest } = referenceHook()
|
||||||
|
return { ...data, ...rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTranslations = (
|
||||||
|
query?: HttpTypes.AdminTranslationsListParams,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
HttpTypes.AdminTranslationsListResponse,
|
||||||
|
FetchError,
|
||||||
|
HttpTypes.AdminTranslationsListResponse,
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
"queryFn" | "queryKey"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...rest } = useQuery({
|
||||||
|
queryKey: translationsQueryKeys.list(query),
|
||||||
|
queryFn: () => sdk.admin.translation.list(query),
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceInvalidationKeysMap = new Map<string, QueryKey>([
|
||||||
|
["product", productsQueryKeys.lists()],
|
||||||
|
["product_variant", productVariantQueryKeys.lists()],
|
||||||
|
["product_category", categoriesQueryKeys.lists()],
|
||||||
|
["product_collection", collectionsQueryKeys.lists()],
|
||||||
|
["product_type", productTypesQueryKeys.lists()],
|
||||||
|
["product_tag", productTagsQueryKeys.lists()],
|
||||||
|
])
|
||||||
|
|
||||||
|
export const useBatchTranslations = (
|
||||||
|
reference: string,
|
||||||
|
options?: UseMutationOptions<
|
||||||
|
HttpTypes.AdminTranslationsBatchResponse,
|
||||||
|
FetchError,
|
||||||
|
HttpTypes.AdminBatchTranslations
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (payload: HttpTypes.AdminBatchTranslations) =>
|
||||||
|
sdk.admin.translation.batch(payload),
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Useful to call the invalidation separately from the batch request and await the refetch finishes.
|
||||||
|
*/
|
||||||
|
const invalidateQueries = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: referenceInvalidationKeysMap.get(reference),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: translationStatisticsQueryKeys.lists(),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mutation,
|
||||||
|
invalidateQueries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTranslationSettings = (
|
||||||
|
query?: HttpTypes.AdminTranslationSettingsParams,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
HttpTypes.AdminTranslationSettingsResponse,
|
||||||
|
FetchError,
|
||||||
|
HttpTypes.AdminTranslationSettingsResponse,
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
"queryFn" | "queryKey"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...rest } = useQuery({
|
||||||
|
queryKey: translationSettingsQueryKeys.list(query),
|
||||||
|
queryFn: () => sdk.admin.translation.settings(query),
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...rest }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTranslationStatistics = (
|
||||||
|
query?: HttpTypes.AdminTranslationStatisticsParams,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
HttpTypes.AdminTranslationStatisticsResponse,
|
||||||
|
FetchError,
|
||||||
|
HttpTypes.AdminTranslationStatisticsResponse,
|
||||||
|
QueryKey
|
||||||
|
>,
|
||||||
|
"queryFn" | "queryKey"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { data, ...rest } = useQuery({
|
||||||
|
queryKey: translationStatisticsQueryKeys.list(query),
|
||||||
|
queryFn: () => sdk.admin.translation.statistics(query),
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...data, ...rest }
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { FetchError } from "@medusajs/js-sdk"
|
||||||
|
import { PaginatedResponse } from "@medusajs/types"
|
||||||
|
import {
|
||||||
|
QueryKey,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
|
InfiniteData,
|
||||||
|
useInfiniteQuery,
|
||||||
|
} from "@tanstack/react-query"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic hook for infinite queries with pagination support.
|
||||||
|
*
|
||||||
|
* @template TResponse - The response type that must include count, offset, and limit
|
||||||
|
* @template TParams - The query parameters type (offset and limit will be handled internally)
|
||||||
|
* @template TError - The error type (defaults to FetchError)
|
||||||
|
* @template TQueryKey - The query key type (defaults to QueryKey)
|
||||||
|
*
|
||||||
|
* @param config - Configuration object
|
||||||
|
* @param config.queryKey - Function or array that generates the query key
|
||||||
|
* @param config.queryFn - Function that fetches data with offset and limit
|
||||||
|
* @param config.query - Query parameters (offset is ignored, limit is optional)
|
||||||
|
* @param config.options - Additional options for useInfiniteQuery
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const { data, fetchNextPage, hasNextPage } = useInfiniteList({
|
||||||
|
* queryKey: (params) => productVariantQueryKeys.list(params),
|
||||||
|
* queryFn: async (params) => sdk.admin.productVariant.list(params),
|
||||||
|
* query: { status: "published" },
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useInfiniteList = <
|
||||||
|
TResponse extends PaginatedResponse<unknown>,
|
||||||
|
TParams extends { offset?: number; limit?: number } = {
|
||||||
|
offset?: number
|
||||||
|
limit?: number
|
||||||
|
},
|
||||||
|
TError = FetchError,
|
||||||
|
TQueryKey extends QueryKey = QueryKey
|
||||||
|
>({
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
query,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
queryKey: ((params: Omit<TParams, "limit">) => TQueryKey) | TQueryKey
|
||||||
|
queryFn: (params: TParams) => Promise<TResponse>
|
||||||
|
query?: TParams
|
||||||
|
options?: Omit<
|
||||||
|
UseInfiniteQueryOptions<
|
||||||
|
TResponse,
|
||||||
|
TError,
|
||||||
|
InfiniteData<TResponse, number>,
|
||||||
|
TResponse,
|
||||||
|
TQueryKey,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
"queryKey" | "queryFn" | "initialPageParam" | "getNextPageParam"
|
||||||
|
>
|
||||||
|
}) => {
|
||||||
|
const { limit = 50, offset: _, ..._query } = query ?? {}
|
||||||
|
const resolvedQueryKey =
|
||||||
|
typeof queryKey === "function"
|
||||||
|
? queryKey(_query as Omit<TParams, "limit">)
|
||||||
|
: queryKey
|
||||||
|
const infiniteQueryKey =
|
||||||
|
resolvedQueryKey[resolvedQueryKey.length - 1] === "__infinite"
|
||||||
|
? resolvedQueryKey
|
||||||
|
: ([...resolvedQueryKey, "__infinite"] as unknown as TQueryKey)
|
||||||
|
|
||||||
|
return useInfiniteQuery<
|
||||||
|
TResponse,
|
||||||
|
TError,
|
||||||
|
InfiniteData<TResponse, number>,
|
||||||
|
TQueryKey,
|
||||||
|
number
|
||||||
|
>({
|
||||||
|
// Infinite queries must not share the exact same queryKey as non-infinite queries,
|
||||||
|
// since the cached data shape differs (InfiniteData<T> vs T).
|
||||||
|
queryKey: infiniteQueryKey,
|
||||||
|
queryFn: ({ pageParam = 0 }) => {
|
||||||
|
return queryFn({ ..._query, limit, offset: pageParam } as TParams)
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit
|
||||||
|
return moreItemsExist ? lastPage.offset + lastPage.limit : undefined
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@
|
|||||||
"search": {
|
"search": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"of": {
|
"of": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -104,6 +107,9 @@
|
|||||||
"removed": {
|
"removed": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"remaining": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -191,6 +197,7 @@
|
|||||||
"apply",
|
"apply",
|
||||||
"range",
|
"range",
|
||||||
"search",
|
"search",
|
||||||
|
"original",
|
||||||
"of",
|
"of",
|
||||||
"results",
|
"results",
|
||||||
"pages",
|
"pages",
|
||||||
@@ -213,6 +220,7 @@
|
|||||||
"modified",
|
"modified",
|
||||||
"added",
|
"added",
|
||||||
"removed",
|
"removed",
|
||||||
|
"remaining",
|
||||||
"admin",
|
"admin",
|
||||||
"store",
|
"store",
|
||||||
"details",
|
"details",
|
||||||
@@ -381,6 +389,12 @@
|
|||||||
"save": {
|
"save": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"saveChanges": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"saveAndClose": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"saveAsDraft": {
|
"saveAsDraft": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -513,7 +527,9 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"save",
|
"save",
|
||||||
|
"saveChanges",
|
||||||
"saveAsDraft",
|
"saveAsDraft",
|
||||||
|
"saveAndClose",
|
||||||
"copy",
|
"copy",
|
||||||
"copied",
|
"copied",
|
||||||
"duplicate",
|
"duplicate",
|
||||||
@@ -9328,6 +9344,112 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"translations": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"domain": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subtitle": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"metrics": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["metrics"]
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"manageLocales": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["manageLocales", "manage"]
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"successToast": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"unsavedChanges": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title", "description"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["successToast", "unsavedChanges"]
|
||||||
|
},
|
||||||
|
"bulk": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"header": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mainColumn": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["header"]
|
||||||
|
},
|
||||||
|
"activeLocales": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"heading": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subtitle": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"noLocalesTip": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["heading", "subtitle", "noLocalesTip"]
|
||||||
|
},
|
||||||
|
"completion": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"heading": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"translated": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"toTranslate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["heading", "translated", "toTranslate", "footer"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"domain",
|
||||||
|
"actions",
|
||||||
|
"subtitle",
|
||||||
|
"bulk",
|
||||||
|
"completion",
|
||||||
|
"edit"
|
||||||
|
]
|
||||||
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"range": "Range",
|
"range": "Range",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
|
"original": "Original",
|
||||||
"of": "of",
|
"of": "of",
|
||||||
"results": "results",
|
"results": "results",
|
||||||
"pages": "pages",
|
"pages": "pages",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"modified": "Modified",
|
"modified": "Modified",
|
||||||
"added": "Added",
|
"added": "Added",
|
||||||
"removed": "Removed",
|
"removed": "Removed",
|
||||||
|
"remaining": "Left",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"store": "Store",
|
"store": "Store",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
@@ -99,7 +101,9 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
"saveAsDraft": "Save as draft",
|
"saveAsDraft": "Save as draft",
|
||||||
|
"saveAndClose": "Save and close",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
@@ -2504,6 +2508,40 @@
|
|||||||
"deleteUserSuccess": "User {{name}} deleted successfully",
|
"deleteUserSuccess": "User {{name}} deleted successfully",
|
||||||
"invite": "Invite"
|
"invite": "Invite"
|
||||||
},
|
},
|
||||||
|
"translations": {
|
||||||
|
"domain": "Translations",
|
||||||
|
"actions": {
|
||||||
|
"manage": "Manage translations",
|
||||||
|
"manageLocales": "Manage locales"
|
||||||
|
},
|
||||||
|
"title": "Translation domains",
|
||||||
|
"subtitle": "Manage translations of your data in Medusa",
|
||||||
|
"list": {
|
||||||
|
"metrics": "{{translated}} of {{total}} fields translated"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"successToast": "Translations updated successfully",
|
||||||
|
"unsavedChanges": {
|
||||||
|
"title": "Unsaved translations",
|
||||||
|
"description": "Don't lose your work. You have changes that haven't been saved yet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bulk": {
|
||||||
|
"header": "Translations Bulk Editor",
|
||||||
|
"mainColumn": "Locale"
|
||||||
|
},
|
||||||
|
"activeLocales": {
|
||||||
|
"heading": "Languages",
|
||||||
|
"subtitle": "Activated translations",
|
||||||
|
"noLocalesTip": "Configure at least one locale to start translating your data"
|
||||||
|
},
|
||||||
|
"completion": {
|
||||||
|
"heading": "Translated fields",
|
||||||
|
"translated": "Translated",
|
||||||
|
"toTranslate": "Missing",
|
||||||
|
"footer": "Languages"
|
||||||
|
}
|
||||||
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"domain": "Store",
|
"domain": "Store",
|
||||||
"manageYourStoresDetails": "Manage your store's details",
|
"manageYourStoresDetails": "Manage your store's details",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"apply": "Aplicar",
|
"apply": "Aplicar",
|
||||||
"range": "Rango",
|
"range": "Rango",
|
||||||
"search": "Buscar",
|
"search": "Buscar",
|
||||||
|
"original": "Original",
|
||||||
"of": "de",
|
"of": "de",
|
||||||
"results": "resultados",
|
"results": "resultados",
|
||||||
"pages": "páginas",
|
"pages": "páginas",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"modified": "Modificado",
|
"modified": "Modificado",
|
||||||
"added": "Agregado",
|
"added": "Agregado",
|
||||||
"removed": "Eliminado",
|
"removed": "Eliminado",
|
||||||
|
"remaining": "Restantes",
|
||||||
"admin": "Administrador",
|
"admin": "Administrador",
|
||||||
"store": "Tienda",
|
"store": "Tienda",
|
||||||
"details": "Detalles",
|
"details": "Detalles",
|
||||||
@@ -120,7 +122,9 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
|
"saveChanges": "Guardar cambios",
|
||||||
"saveAsDraft": "Guardar como borrador",
|
"saveAsDraft": "Guardar como borrador",
|
||||||
|
"saveAndClose": "Guardar y cerrar",
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"copied": "Copiado",
|
"copied": "Copiado",
|
||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
@@ -2443,6 +2447,40 @@
|
|||||||
"deleteUserSuccess": "Usuario {{name}} eliminado correctamente",
|
"deleteUserSuccess": "Usuario {{name}} eliminado correctamente",
|
||||||
"invite": "Invitar"
|
"invite": "Invitar"
|
||||||
},
|
},
|
||||||
|
"translations": {
|
||||||
|
"domain": "Traducciones",
|
||||||
|
"list": {
|
||||||
|
"metrics": "{{translated}} de {{total}} textos traducidos"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"manage": "Gestionar traducciones",
|
||||||
|
"manageLocales": "Gestionar idiomas"
|
||||||
|
},
|
||||||
|
"title": "Traducción de dominios",
|
||||||
|
"subtitle": "Gestiona las traducciones de tus datos en Medusa",
|
||||||
|
"bulk": {
|
||||||
|
"header": "Editor de Traducciones en Masa",
|
||||||
|
"locale": "Idioma"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"successToast": "Traducciones actualizadas exitosamente",
|
||||||
|
"unsavedChanges": {
|
||||||
|
"title": "Traducciones sin guardar",
|
||||||
|
"description": "No pierdas tu trabajo. Tienes cambios que no han sido guardados aún"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activeLocales": {
|
||||||
|
"heading": "Idiomas",
|
||||||
|
"subtitle": "Traducciones activas",
|
||||||
|
"noLocalesTip": "Configura al menos un idioma para empezar a traducir tu información"
|
||||||
|
},
|
||||||
|
"completion": {
|
||||||
|
"heading": "Textos traducidos",
|
||||||
|
"translated": "Traducidos",
|
||||||
|
"toTranslate": "Faltantes",
|
||||||
|
"footer": "Idiomas"
|
||||||
|
}
|
||||||
|
},
|
||||||
"store": {
|
"store": {
|
||||||
"domain": "Tienda",
|
"domain": "Tienda",
|
||||||
"manageYourStoresDetails": "Gestiona los detalles de tu tienda",
|
"manageYourStoresDetails": "Gestiona los detalles de tu tienda",
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import { Container, Heading, StatusBadge, Text } from "@medusajs/ui"
|
import { Container, Heading, StatusBadge, Text } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
|
import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
|
||||||
import { getIsActiveProps, getIsInternalProps } from "../../../common/utils"
|
import { getIsActiveProps, getIsInternalProps } from "../../../common/utils"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
type CategoryGeneralSectionProps = {
|
type CategoryGeneralSectionProps = {
|
||||||
category: HttpTypes.AdminProductCategory
|
category: HttpTypes.AdminProductCategory
|
||||||
@@ -14,6 +15,7 @@ export const CategoryGeneralSection = ({
|
|||||||
category,
|
category,
|
||||||
}: CategoryGeneralSectionProps) => {
|
}: CategoryGeneralSectionProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
const activeProps = getIsActiveProps(category.is_active, t)
|
const activeProps = getIsActiveProps(category.is_active, t)
|
||||||
const internalProps = getIsInternalProps(category.is_internal, t)
|
const internalProps = getIsInternalProps(category.is_internal, t)
|
||||||
@@ -44,6 +46,19 @@ export const CategoryGeneralSection = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_category&reference_id=${category.id}`,
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { AdminProductCategoryResponse } from "@medusajs/types"
|
||||||
import { Button, Container, Heading, Text } from "@medusajs/ui"
|
import { Button, Container, Heading, Text } from "@medusajs/ui"
|
||||||
import { keepPreviousData } from "@tanstack/react-query"
|
import { keepPreviousData } from "@tanstack/react-query"
|
||||||
@@ -14,6 +14,7 @@ import { useDataTable } from "../../../../../hooks/use-data-table"
|
|||||||
import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
|
import { useDeleteProductCategoryAction } from "../../../common/hooks/use-delete-product-category-action"
|
||||||
import { useCategoryTableColumns } from "./use-category-table-columns"
|
import { useCategoryTableColumns } from "./use-category-table-columns"
|
||||||
import { useCategoryTableQuery } from "./use-category-table-query"
|
import { useCategoryTableQuery } from "./use-category-table-query"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ const CategoryRowActions = ({
|
|||||||
category: AdminProductCategoryResponse["product_category"]
|
category: AdminProductCategoryResponse["product_category"]
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
const handleDelete = useDeleteProductCategoryAction(category)
|
const handleDelete = useDeleteProductCategoryAction(category)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,6 +121,19 @@ const CategoryRowActions = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_category&reference_id=${category.id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import { Container, Heading, Text, usePrompt } from "@medusajs/ui"
|
import { Container, Heading, Text, usePrompt } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { useDeleteCollection } from "../../../../../hooks/api/collections"
|
import { useDeleteCollection } from "../../../../../hooks/api/collections"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
type CollectionGeneralSectionProps = {
|
type CollectionGeneralSectionProps = {
|
||||||
collection: HttpTypes.AdminCollection
|
collection: HttpTypes.AdminCollection
|
||||||
@@ -16,6 +17,7 @@ export const CollectionGeneralSection = ({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
const { mutateAsync } = useDeleteCollection(collection.id!)
|
const { mutateAsync } = useDeleteCollection(collection.id!)
|
||||||
|
|
||||||
@@ -52,6 +54,19 @@ export const CollectionGeneralSection = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_collection&reference_id=${collection.id}`,
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import { usePrompt } from "@medusajs/ui"
|
import { usePrompt } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { useDeleteCollection } from "../../../../../hooks/api/collections"
|
import { useDeleteCollection } from "../../../../../hooks/api/collections"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
export const CollectionRowActions = ({
|
export const CollectionRowActions = ({
|
||||||
collection,
|
collection,
|
||||||
@@ -13,6 +14,7 @@ export const CollectionRowActions = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
const { mutateAsync } = useDeleteCollection(collection.id!)
|
const { mutateAsync } = useDeleteCollection(collection.id!)
|
||||||
|
|
||||||
@@ -47,6 +49,19 @@ export const CollectionRowActions = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_collection&reference_id=${collection.id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import { Container, Heading } from "@medusajs/ui"
|
import { Container, Heading } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
|
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
type ProductTagGeneralSectionProps = {
|
type ProductTagGeneralSectionProps = {
|
||||||
productTag: HttpTypes.AdminProductTag
|
productTag: HttpTypes.AdminProductTag
|
||||||
@@ -14,6 +15,7 @@ export const ProductTagGeneralSection = ({
|
|||||||
}: ProductTagGeneralSectionProps) => {
|
}: ProductTagGeneralSectionProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const handleDelete = useDeleteProductTagAction({ productTag })
|
const handleDelete = useDeleteProductTagAction({ productTag })
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="flex items-center justify-between">
|
<Container className="flex items-center justify-between">
|
||||||
@@ -32,6 +34,19 @@ export const ProductTagGeneralSection = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_tag&reference_id=${productTag.id}`,
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import { Button, Container, Heading } from "@medusajs/ui"
|
import { Button, Container, Heading } from "@medusajs/ui"
|
||||||
import { keepPreviousData } from "@tanstack/react-query"
|
import { keepPreviousData } from "@tanstack/react-query"
|
||||||
@@ -16,6 +16,7 @@ import { useProductTagTableQuery } from "../../../../../hooks/table/query"
|
|||||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||||
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
|
import { useDeleteProductTagAction } from "../../../common/hooks/use-delete-product-tag-action"
|
||||||
import { productTagListLoader } from "../../loader"
|
import { productTagListLoader } from "../../loader"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ const ProductTagRowActions = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const handleDelete = useDeleteProductTagAction({ productTag })
|
const handleDelete = useDeleteProductTagAction({ productTag })
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionMenu
|
<ActionMenu
|
||||||
@@ -101,6 +103,19 @@ const ProductTagRowActions = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_tag&reference_id=${productTag.id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import { Container, Heading } from "@medusajs/ui"
|
import { Container, Heading } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
|
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
type ProductTypeGeneralSectionProps = {
|
type ProductTypeGeneralSectionProps = {
|
||||||
productType: HttpTypes.AdminProductType
|
productType: HttpTypes.AdminProductType
|
||||||
@@ -17,6 +18,7 @@ export const ProductTypeGeneralSection = ({
|
|||||||
productType.id,
|
productType.id,
|
||||||
productType.value
|
productType.value
|
||||||
)
|
)
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="flex items-center justify-between">
|
<Container className="flex items-center justify-between">
|
||||||
@@ -32,6 +34,19 @@ export const ProductTypeGeneralSection = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_type&reference_id=${productType.id}`,
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
|
import { useDeleteProductTypeAction } from "../../../common/hooks/use-delete-product-type-action"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
type ProductTypeRowActionsProps = {
|
type ProductTypeRowActionsProps = {
|
||||||
productType: HttpTypes.AdminProductType
|
productType: HttpTypes.AdminProductType
|
||||||
@@ -16,6 +17,7 @@ export const ProductTypeRowActions = ({
|
|||||||
productType.id,
|
productType.id,
|
||||||
productType.value
|
productType.value
|
||||||
)
|
)
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionMenu
|
<ActionMenu
|
||||||
@@ -29,6 +31,19 @@ export const ProductTypeRowActions = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_type&reference_id=${productType.id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
|
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
@@ -7,6 +7,7 @@ import { useNavigate } from "react-router-dom"
|
|||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
import { SectionRow } from "../../../../../components/common/section"
|
import { SectionRow } from "../../../../../components/common/section"
|
||||||
import { useDeleteVariant } from "../../../../../hooks/api/products"
|
import { useDeleteVariant } from "../../../../../hooks/api/products"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
type VariantGeneralSectionProps = {
|
type VariantGeneralSectionProps = {
|
||||||
variant: HttpTypes.AdminProductVariant
|
variant: HttpTypes.AdminProductVariant
|
||||||
@@ -16,6 +17,7 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
const hasInventoryKit = variant.inventory?.length > 1
|
const hasInventoryKit = variant.inventory?.length > 1
|
||||||
|
|
||||||
@@ -70,6 +72,19 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_variant&reference_id=${variant.id}`,
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import { Container, Heading, StatusBadge, toast, usePrompt } from "@medusajs/ui"
|
import { Container, Heading, StatusBadge, toast, usePrompt } from "@medusajs/ui"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
@@ -8,6 +8,7 @@ import { ActionMenu } from "../../../../../components/common/action-menu"
|
|||||||
import { SectionRow } from "../../../../../components/common/section"
|
import { SectionRow } from "../../../../../components/common/section"
|
||||||
import { useDeleteProduct } from "../../../../../hooks/api/products"
|
import { useDeleteProduct } from "../../../../../hooks/api/products"
|
||||||
import { useExtension } from "../../../../../providers/extension-provider"
|
import { useExtension } from "../../../../../providers/extension-provider"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
const productStatusColor = (status: string) => {
|
const productStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -35,6 +36,7 @@ export const ProductGeneralSection = ({
|
|||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { getDisplays } = useExtension()
|
const { getDisplays } = useExtension()
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
const displays = getDisplays("product", "general")
|
const displays = getDisplays("product", "general")
|
||||||
|
|
||||||
@@ -85,6 +87,19 @@ export const ProductGeneralSection = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product&reference_id=${product.id}`,
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
+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 { HttpTypes } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -26,6 +32,7 @@ import {
|
|||||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||||
import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants"
|
import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants"
|
||||||
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
||||||
|
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
type ProductVariantSectionProps = {
|
type ProductVariantSectionProps = {
|
||||||
product: HttpTypes.AdminProduct
|
product: HttpTypes.AdminProduct
|
||||||
@@ -38,6 +45,7 @@ export const ProductVariantSection = ({
|
|||||||
product,
|
product,
|
||||||
}: ProductVariantSectionProps) => {
|
}: ProductVariantSectionProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
const { q, order, offset, allow_backorder, manage_inventory } =
|
const { q, order, offset, allow_backorder, manage_inventory } =
|
||||||
useQueryParams(
|
useQueryParams(
|
||||||
@@ -70,6 +78,11 @@ export const ProductVariantSection = ({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const translationParams = new URLSearchParams()
|
||||||
|
variants?.forEach((variant) => {
|
||||||
|
translationParams.append("reference_id", variant.id)
|
||||||
|
})
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -115,6 +128,15 @@ export const ProductVariantSection = ({
|
|||||||
to: `stock`,
|
to: `stock`,
|
||||||
icon: <Buildings />,
|
icon: <Buildings />,
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product_variant&${translationParams.toString()}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -225,6 +247,15 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
onClick: () => {
|
||||||
|
navigate(
|
||||||
|
`/settings/translations/edit?reference=product_variant&reference_id=${variant.id}`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const secondaryActions: DataTableAction<HttpTypes.AdminProductVariant>[] =
|
const secondaryActions: DataTableAction<HttpTypes.AdminProductVariant>[] =
|
||||||
|
|||||||
+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 { Button, Container, Heading, toast, usePrompt } from "@medusajs/ui"
|
||||||
import { keepPreviousData } from "@tanstack/react-query"
|
import { keepPreviousData } from "@tanstack/react-query"
|
||||||
import { createColumnHelper } from "@tanstack/react-table"
|
import { createColumnHelper } from "@tanstack/react-table"
|
||||||
@@ -110,6 +110,7 @@ const ProductActions = ({ product }: { product: HttpTypes.AdminProduct }) => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const { mutateAsync } = useDeleteProduct(product.id)
|
const { mutateAsync } = useDeleteProduct(product.id)
|
||||||
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
const res = await prompt({
|
const res = await prompt({
|
||||||
@@ -153,6 +154,19 @@ const ProductActions = ({ product }: { product: HttpTypes.AdminProduct }) => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(isTranslationsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <GlobeEurope />,
|
||||||
|
label: t("translations.actions.manage"),
|
||||||
|
to: `/settings/translations/edit?reference=product&reference_id=${product.id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { RouteFocusModal } from "../../../components/modals/route-focus-modal"
|
||||||
|
import { useStore } from "../../../hooks/api"
|
||||||
|
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { AddLocalesForm } from "../../store/store-add-locales/components/add-locales-form/add-locales-form"
|
||||||
|
|
||||||
|
export const TranslationsAddLocales = () => {
|
||||||
|
const isEnabled = useFeatureFlag("translation")
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
navigate(-1)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { store, isPending, isError, error } = useStore()
|
||||||
|
|
||||||
|
const ready = !!store && !isPending
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteFocusModal>
|
||||||
|
{ready && <AddLocalesForm store={store} />}
|
||||||
|
</RouteFocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { TranslationsAddLocales as Component } from "./add-locales"
|
||||||
+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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ export const validateTranslationsStep = createStep(
|
|||||||
const unsupportedLocales = normalizedInput
|
const unsupportedLocales = normalizedInput
|
||||||
.filter((translation) => Boolean(translation.locale_code))
|
.filter((translation) => Boolean(translation.locale_code))
|
||||||
.map((translation) => translation.locale_code)
|
.map((translation) => translation.locale_code)
|
||||||
.filter((locale) => !enabledLocales.includes(locale ?? ""))
|
.filter((locale) => !enabledLocales.includes(locale))
|
||||||
|
|
||||||
if (unsupportedLocales.length) {
|
if (unsupportedLocales.length) {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { Views } from "./views"
|
|||||||
import { WorkflowExecution } from "./workflow-execution"
|
import { WorkflowExecution } from "./workflow-execution"
|
||||||
import { ShippingOptionType } from "./shipping-option-type"
|
import { ShippingOptionType } from "./shipping-option-type"
|
||||||
import { Locale } from "./locale"
|
import { Locale } from "./locale"
|
||||||
|
import { Translation } from "./translation"
|
||||||
|
|
||||||
export class Admin {
|
export class Admin {
|
||||||
/**
|
/**
|
||||||
@@ -225,6 +226,10 @@ export class Admin {
|
|||||||
* @tags tax
|
* @tags tax
|
||||||
*/
|
*/
|
||||||
public taxProvider: TaxProvider
|
public taxProvider: TaxProvider
|
||||||
|
/**
|
||||||
|
* @tags translations
|
||||||
|
*/
|
||||||
|
public translation: Translation
|
||||||
/**
|
/**
|
||||||
* @tags promotion
|
* @tags promotion
|
||||||
*/
|
*/
|
||||||
@@ -268,6 +273,7 @@ export class Admin {
|
|||||||
this.claim = new Claim(client)
|
this.claim = new Claim(client)
|
||||||
this.taxRate = new TaxRate(client)
|
this.taxRate = new TaxRate(client)
|
||||||
this.taxRegion = new TaxRegion(client)
|
this.taxRegion = new TaxRegion(client)
|
||||||
|
this.translation = new Translation(client)
|
||||||
this.store = new Store(client)
|
this.store = new Store(client)
|
||||||
this.productTag = new ProductTag(client)
|
this.productTag = new ProductTag(client)
|
||||||
this.user = new User(client)
|
this.user = new User(client)
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { Client } from "../client"
|
||||||
|
import { ClientHeaders } from "../types"
|
||||||
|
|
||||||
|
export class Translation {
|
||||||
|
/**
|
||||||
|
* @ignore
|
||||||
|
*/
|
||||||
|
private client: Client
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ignore
|
||||||
|
*/
|
||||||
|
constructor(client: Client) {
|
||||||
|
this.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method retrieves a paginated list of translations. It sends a request to the
|
||||||
|
* [List Translations](https://docs.medusajs.com/api/admin#translations_gettranslations)
|
||||||
|
* API route.
|
||||||
|
*
|
||||||
|
* @param query - Filters and pagination configurations.
|
||||||
|
* @param headers - Headers to pass in the request.
|
||||||
|
* @returns The paginated list of translations.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* To retrieve the list of translations:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* sdk.admin.translation.list()
|
||||||
|
* .then(({ translations, count, limit, offset }) => {
|
||||||
|
* console.log(translations)
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* To configure the pagination, pass the `limit` and `offset` query parameters.
|
||||||
|
*
|
||||||
|
* For example, to retrieve only 10 items and skip 10 items:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* sdk.admin.translation.list({
|
||||||
|
* limit: 10,
|
||||||
|
* offset: 10
|
||||||
|
* })
|
||||||
|
* .then(({ translations, count, limit, offset }) => {
|
||||||
|
* console.log(translations)
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
|
||||||
|
* in each translation:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* sdk.admin.translation.list({
|
||||||
|
* fields: "id,name"
|
||||||
|
* })
|
||||||
|
* .then(({ translations, count, limit, offset }) => {
|
||||||
|
* console.log(translations)
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
|
||||||
|
*/
|
||||||
|
async list(
|
||||||
|
query?: HttpTypes.AdminTranslationsListParams,
|
||||||
|
headers?: ClientHeaders
|
||||||
|
) {
|
||||||
|
return await this.client.fetch<HttpTypes.AdminTranslationsListResponse>(
|
||||||
|
`/admin/translations`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
query,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method allows bulk operations on translations. It sends a request to the
|
||||||
|
* [Manage Translations](https://docs.medusajs.com/api/admin#translations_posttranslationsbatch)
|
||||||
|
* API route.
|
||||||
|
*
|
||||||
|
* @param payload - The translations to create, update, or delete.
|
||||||
|
* @param headers - Headers to pass in the request.
|
||||||
|
* @returns The translations' details.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* sdk.admin.translation.batch({
|
||||||
|
* create: [
|
||||||
|
* {
|
||||||
|
* reference_id: "prod_123",
|
||||||
|
* reference: "product",
|
||||||
|
* locale_code: "en-US",
|
||||||
|
* translations: { title: "Shirt" }
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
|
* update: [
|
||||||
|
* {
|
||||||
|
* id: "trans_123",
|
||||||
|
* translations: { title: "Pants" }
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
|
* delete: ["trans_321"]
|
||||||
|
* })
|
||||||
|
* .then(({ created, updated, deleted }) => {
|
||||||
|
* console.log(created, updated, deleted)
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async batch(body: HttpTypes.AdminBatchTranslations, headers?: ClientHeaders) {
|
||||||
|
return await this.client.fetch<HttpTypes.AdminTranslationsBatchResponse>(
|
||||||
|
`/admin/translations/batch`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method retrieves the settings for the translations for a given entity type or all entity types if no entity type is provided.
|
||||||
|
* It sends a request to the
|
||||||
|
* [Get Translation Settings](https://docs.medusajs.com/api/admin#translations_gettranslationssettings) API route.
|
||||||
|
*
|
||||||
|
* @param query - The query parameters which can optionally include the entity type to get the settings for.
|
||||||
|
* @param headers - Headers to pass in the request.
|
||||||
|
* @returns The translation settings.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* To retrieve the settings for the translations for a given entity type:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* sdk.admin.translation.settings({
|
||||||
|
* entity_type: "product"
|
||||||
|
* })
|
||||||
|
* .then(({ translatable_fields }) => {
|
||||||
|
* console.log(translatable_fields)
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* To retrieve the settings for all entity types:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* sdk.admin.translation.settings()
|
||||||
|
* .then(({ translatable_fields }) => {
|
||||||
|
* console.log(translatable_fields)
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async settings(
|
||||||
|
query?: HttpTypes.AdminTranslationSettingsParams,
|
||||||
|
headers?: ClientHeaders
|
||||||
|
) {
|
||||||
|
return await this.client.fetch<HttpTypes.AdminTranslationSettingsResponse>(
|
||||||
|
`/admin/translations/settings`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
query,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method retrieves the statistics for the translations for a given entity type or all entity types if no entity type is provided.
|
||||||
|
* It sends a request to the
|
||||||
|
* [Get Translation Statistics](https://docs.medusajs.com/api/admin#translations_gettranslationsstatistics) API route.
|
||||||
|
*
|
||||||
|
* @param query - The query parameters which can optionally include the entity type to get the statistics for.
|
||||||
|
* @param headers - Headers to pass in the request.
|
||||||
|
* @returns The translation statistics.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* To retrieve the statistics for the translations for a given entity type:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* sdk.admin.translation.statistics({
|
||||||
|
* entity_type: "product"
|
||||||
|
* })
|
||||||
|
* .then(({ statistics }) => {
|
||||||
|
* console.log(statistics)
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* To retrieve the statistics for all entity types:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* sdk.admin.translation.statistics()
|
||||||
|
* .then(({ statistics }) => {
|
||||||
|
* console.log(statistics)
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async statistics(
|
||||||
|
query?: HttpTypes.AdminTranslationStatisticsParams,
|
||||||
|
headers?: ClientHeaders
|
||||||
|
) {
|
||||||
|
return await this.client.fetch<HttpTypes.AdminTranslationStatisticsResponse>(
|
||||||
|
`/admin/translations/statistics`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
query,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
import { BaseCollection } from "../common"
|
import { BaseCollection } from "../common"
|
||||||
|
import { AdminTranslation } from "../../translations"
|
||||||
|
|
||||||
export interface AdminCollection extends BaseCollection {}
|
export interface AdminCollection extends BaseCollection {
|
||||||
|
/**
|
||||||
|
* The collection's translations.
|
||||||
|
*/
|
||||||
|
translations?: AdminTranslation[] | null
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AdminProduct } from "../../product"
|
import { AdminProduct } from "../../product"
|
||||||
|
import { AdminTranslation } from "../../translations"
|
||||||
import { BaseProductCategory } from "../common"
|
import { BaseProductCategory } from "../common"
|
||||||
|
|
||||||
export interface AdminProductCategory
|
export interface AdminProductCategory
|
||||||
@@ -18,4 +19,8 @@ export interface AdminProductCategory
|
|||||||
* The products that belong to this category.
|
* The products that belong to this category.
|
||||||
*/
|
*/
|
||||||
products?: AdminProduct[]
|
products?: AdminProduct[]
|
||||||
|
/**
|
||||||
|
* The category's translations.
|
||||||
|
*/
|
||||||
|
translations?: AdminTranslation[] | null
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
import { BaseProductTag } from "../common"
|
import { BaseProductTag } from "../common"
|
||||||
|
import { AdminTranslation } from "../../translations"
|
||||||
|
|
||||||
export interface AdminProductTag extends BaseProductTag {}
|
export interface AdminProductTag extends BaseProductTag {
|
||||||
|
/**
|
||||||
|
* The tag's translations.
|
||||||
|
*/
|
||||||
|
translations?: AdminTranslation[] | null
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import { AdminTranslation } from "../../translations"
|
||||||
import { BaseProductType } from "../common"
|
import { BaseProductType } from "../common"
|
||||||
|
|
||||||
export interface AdminProductType extends BaseProductType {}
|
export interface AdminProductType extends BaseProductType {
|
||||||
|
/**
|
||||||
|
* The product type's translations.
|
||||||
|
*/
|
||||||
|
translations?: AdminTranslation[] | null
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { AdminProductTag } from "../../product-tag"
|
|||||||
import { AdminProductType } from "../../product-type"
|
import { AdminProductType } from "../../product-type"
|
||||||
import { AdminSalesChannel } from "../../sales-channel"
|
import { AdminSalesChannel } from "../../sales-channel"
|
||||||
import { AdminShippingProfile } from "../../shipping-profile"
|
import { AdminShippingProfile } from "../../shipping-profile"
|
||||||
|
import { AdminTranslation } from "../../translations"
|
||||||
import {
|
import {
|
||||||
BaseProduct,
|
BaseProduct,
|
||||||
BaseProductImage,
|
BaseProductImage,
|
||||||
@@ -55,6 +56,10 @@ export interface AdminProductVariant extends BaseProductVariant {
|
|||||||
* The product that this variant belongs to.
|
* The product that this variant belongs to.
|
||||||
*/
|
*/
|
||||||
product?: AdminProduct | null
|
product?: AdminProduct | null
|
||||||
|
/**
|
||||||
|
* The product variant's translations.
|
||||||
|
*/
|
||||||
|
translations?: AdminTranslation[] | null
|
||||||
/**
|
/**
|
||||||
* The variant's inventory items.
|
* The variant's inventory items.
|
||||||
*/
|
*/
|
||||||
@@ -108,6 +113,10 @@ export interface AdminProduct
|
|||||||
* The product's variants.
|
* The product's variants.
|
||||||
*/
|
*/
|
||||||
variants: AdminProductVariant[] | null
|
variants: AdminProductVariant[] | null
|
||||||
|
/**
|
||||||
|
* The product's translations.
|
||||||
|
*/
|
||||||
|
translations?: AdminTranslation[] | null
|
||||||
/**
|
/**
|
||||||
* The product's type.
|
* The product's type.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./queries"
|
export * from "./queries"
|
||||||
export * from "./responses"
|
export * from "./responses"
|
||||||
export * from "./entities"
|
export * from "./entities"
|
||||||
|
export * from "./payloads"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
interface AdminCreateTranslation {
|
||||||
|
/**
|
||||||
|
* The ID of the entity being translated.
|
||||||
|
*/
|
||||||
|
reference_id: string
|
||||||
|
/**
|
||||||
|
* The type of entity being translated (e.g., "product", "product_variant").
|
||||||
|
*/
|
||||||
|
reference: string
|
||||||
|
/**
|
||||||
|
* The BCP 47 language tag code for this translation (e.g., "en-US", "fr-FR").
|
||||||
|
*/
|
||||||
|
locale_code: string
|
||||||
|
/**
|
||||||
|
* The translated fields as key-value pairs.
|
||||||
|
*/
|
||||||
|
translations: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminUpdateTranslation {
|
||||||
|
/**
|
||||||
|
* The ID of the translation.
|
||||||
|
*/
|
||||||
|
id: string
|
||||||
|
/**
|
||||||
|
* The translated fields as key-value pairs.
|
||||||
|
*/
|
||||||
|
translations: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBatchTranslations {
|
||||||
|
/**
|
||||||
|
* The translations to create.
|
||||||
|
*/
|
||||||
|
create?: AdminCreateTranslation[]
|
||||||
|
/**
|
||||||
|
* The translations to update.
|
||||||
|
*/
|
||||||
|
update?: AdminUpdateTranslation[]
|
||||||
|
/**
|
||||||
|
* The translations to delete.
|
||||||
|
*/
|
||||||
|
delete?: string[]
|
||||||
|
}
|
||||||
@@ -51,3 +51,13 @@ export interface AdminTranslationStatisticsParams {
|
|||||||
*/
|
*/
|
||||||
entity_types: string[]
|
entity_types: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for translation settings endpoint.
|
||||||
|
*/
|
||||||
|
export interface AdminTranslationSettingsParams {
|
||||||
|
/**
|
||||||
|
* The entity type to get the settings for (e.g., "product").
|
||||||
|
*/
|
||||||
|
entity_type?: string
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export const allowed = [
|
|||||||
"category_children",
|
"category_children",
|
||||||
"parent_category",
|
"parent_category",
|
||||||
"products",
|
"products",
|
||||||
|
"translations",
|
||||||
]
|
]
|
||||||
|
|
||||||
export const retrieveProductCategoryConfig = {
|
export const retrieveProductCategoryConfig = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
AdminBatchTranslations,
|
AdminBatchTranslations,
|
||||||
AdminGetTranslationsParams,
|
AdminGetTranslationsParams,
|
||||||
|
AdminTranslationSettingsParams,
|
||||||
AdminTranslationStatistics,
|
AdminTranslationStatistics,
|
||||||
} from "./validators"
|
} from "./validators"
|
||||||
import * as QueryConfig from "./query-config"
|
import * as QueryConfig from "./query-config"
|
||||||
@@ -38,6 +39,8 @@ export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
{
|
{
|
||||||
method: ["GET"],
|
method: ["GET"],
|
||||||
matcher: "/admin/translations/settings",
|
matcher: "/admin/translations/settings",
|
||||||
middlewares: [],
|
middlewares: [
|
||||||
|
validateAndTransformQuery(AdminTranslationSettingsParams, {}),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -9,19 +9,25 @@ import {
|
|||||||
Modules,
|
Modules,
|
||||||
} from "@medusajs/framework/utils"
|
} from "@medusajs/framework/utils"
|
||||||
import TranslationFeatureFlag from "../../../../feature-flags/translation"
|
import TranslationFeatureFlag from "../../../../feature-flags/translation"
|
||||||
|
import { AdminTranslationSettingsParamsType } from "../validators"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since 2.12.3
|
* @since 2.12.3
|
||||||
* @featureFlag translation
|
* @featureFlag translation
|
||||||
*/
|
*/
|
||||||
export const GET = async (
|
export const GET = async (
|
||||||
req: AuthenticatedMedusaRequest,
|
req: AuthenticatedMedusaRequest<
|
||||||
|
undefined,
|
||||||
|
AdminTranslationSettingsParamsType
|
||||||
|
>,
|
||||||
res: MedusaResponse<HttpTypes.AdminTranslationSettingsResponse>
|
res: MedusaResponse<HttpTypes.AdminTranslationSettingsResponse>
|
||||||
) => {
|
) => {
|
||||||
const translationService = req.scope.resolve<ITranslationModuleService>(
|
const translationService = req.scope.resolve<ITranslationModuleService>(
|
||||||
Modules.TRANSLATION
|
Modules.TRANSLATION
|
||||||
)
|
)
|
||||||
const translatable_fields = translationService.getTranslatableFields()
|
const translatable_fields = translationService.getTranslatableFields(
|
||||||
|
req.validatedQuery.entity_type
|
||||||
|
)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
translatable_fields,
|
translatable_fields,
|
||||||
|
|||||||
@@ -64,3 +64,10 @@ export const AdminTranslationStatistics = z
|
|||||||
? data.entity_types
|
? data.entity_types
|
||||||
: [data.entity_types],
|
: [data.entity_types],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
export type AdminTranslationSettingsParamsType = z.infer<
|
||||||
|
typeof AdminTranslationSettingsParams
|
||||||
|
>
|
||||||
|
export const AdminTranslationSettingsParams = z.object({
|
||||||
|
entity_type: z.string().optional(),
|
||||||
|
})
|
||||||
Reference in new issue
Block a user