616 lines
18 KiB
TypeScript
616 lines
18 KiB
TypeScript
import * as React from "react"
|
|
import {
|
|
CircleAlertIcon,
|
|
CheckIcon,
|
|
FileCode2Icon,
|
|
RotateCcwIcon,
|
|
SaveIcon,
|
|
SearchIcon,
|
|
} from "@workspace/ui/components/icon"
|
|
|
|
import { useDevtoolLocalization } from "./devtool-localization"
|
|
import { formatMessageOrigin, MessageCopyMenu } from "./message-copy-menu"
|
|
import {
|
|
createMessageNavigationModel,
|
|
MessageNavigation,
|
|
} from "./message-navigation"
|
|
import { useOptionalMessageRepository } from "./message-repository"
|
|
import type {
|
|
DevtoolMessage,
|
|
DevtoolMessagePackage,
|
|
MessageRepository,
|
|
} from "./types"
|
|
// oxlint-disable-next-line import/no-unassigned-import -- Loads the message panel stylesheet.
|
|
import "./message-panel.css"
|
|
|
|
type LoadState =
|
|
| { status: "loading" }
|
|
| { error: Error; status: "error" }
|
|
| {
|
|
messages: readonly DevtoolMessage[]
|
|
packages: readonly DevtoolMessagePackage[]
|
|
status: "ready"
|
|
}
|
|
|
|
export interface MessagePanelProps {
|
|
className?: string
|
|
locale: string
|
|
refreshToken?: unknown
|
|
repository?: MessageRepository
|
|
}
|
|
|
|
function getMessageSearchText(message: DevtoolMessage) {
|
|
return [
|
|
message.id,
|
|
message.source,
|
|
message.translation,
|
|
message.packageName,
|
|
...message.comments,
|
|
...message.origins.map((origin) => origin.file),
|
|
]
|
|
.join("\n")
|
|
.toLocaleLowerCase()
|
|
}
|
|
|
|
function MessageEditor({
|
|
locale,
|
|
message,
|
|
onSave,
|
|
}: {
|
|
locale: string
|
|
message: DevtoolMessage
|
|
onSave: (translation: string) => Promise<void>
|
|
}) {
|
|
const { messages } = useDevtoolLocalization()
|
|
const [translation, setTranslation] = React.useState(message.translation)
|
|
const [saveState, setSaveState] = React.useState<
|
|
"idle" | "saving" | "saved" | "error"
|
|
>("idle")
|
|
const repositoryTranslationRef = React.useRef(message.translation)
|
|
const submittedTranslationRef = React.useRef<string | undefined>(undefined)
|
|
const isEditable = message.editable
|
|
const isDirty = translation !== message.translation
|
|
const primaryOrigin = message.origins[0]
|
|
|
|
React.useEffect(() => {
|
|
if (repositoryTranslationRef.current === message.translation) {
|
|
return
|
|
}
|
|
|
|
repositoryTranslationRef.current = message.translation
|
|
setTranslation(message.translation)
|
|
|
|
if (submittedTranslationRef.current === message.translation) {
|
|
submittedTranslationRef.current = undefined
|
|
return
|
|
}
|
|
|
|
setSaveState("idle")
|
|
}, [message.translation])
|
|
|
|
React.useEffect(() => {
|
|
if (saveState !== "saved") {
|
|
return
|
|
}
|
|
|
|
const timeout = window.setTimeout(() => setSaveState("idle"), 1600)
|
|
return () => window.clearTimeout(timeout)
|
|
}, [saveState])
|
|
|
|
const handleSave = async () => {
|
|
if (!isEditable || !isDirty || saveState === "saving") {
|
|
return
|
|
}
|
|
|
|
setSaveState("saving")
|
|
submittedTranslationRef.current = translation
|
|
|
|
try {
|
|
await onSave(translation)
|
|
setSaveState("saved")
|
|
} catch {
|
|
submittedTranslationRef.current = undefined
|
|
setSaveState("error")
|
|
}
|
|
}
|
|
|
|
const handleReset = () => {
|
|
submittedTranslationRef.current = undefined
|
|
setTranslation(message.translation)
|
|
setSaveState("idle")
|
|
}
|
|
|
|
return (
|
|
<article
|
|
className="i18n-message"
|
|
data-dirty={isDirty || undefined}
|
|
data-missing={message.missing || undefined}
|
|
data-obsolete={message.obsolete || undefined}
|
|
data-readonly={!isEditable || undefined}
|
|
data-save-state={saveState}
|
|
>
|
|
<header className="i18n-message__source-toolbar">
|
|
<div
|
|
className="i18n-message__source-location"
|
|
title={
|
|
primaryOrigin
|
|
? `${primaryOrigin.file}${primaryOrigin.line ? `:${primaryOrigin.line}` : ""}`
|
|
: messages.messagePanel.unknownSourceLocation
|
|
}
|
|
>
|
|
<FileCode2Icon aria-hidden="true" />
|
|
<code>
|
|
{primaryOrigin
|
|
? formatMessageOrigin(primaryOrigin)
|
|
: messages.messagePanel.unknownSourceLocation}
|
|
</code>
|
|
{message.origins.length > 1 && (
|
|
<span
|
|
aria-label={messages.messagePanel.additionalSourceLocations(
|
|
message.origins.length - 1
|
|
)}
|
|
className="i18n-message__source-count"
|
|
>
|
|
+{message.origins.length - 1}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<MessageCopyMenu message={message} translation={translation} />
|
|
</header>
|
|
|
|
<div className="i18n-message__header">
|
|
<code className="i18n-message__id">{message.id}</code>
|
|
</div>
|
|
|
|
<div className="i18n-message__content">
|
|
<div className="i18n-message__source">
|
|
<span className="i18n-message__label">
|
|
{messages.messagePanel.source}
|
|
<code>{message.sourceLocale}</code>
|
|
</span>
|
|
<p dir="auto" lang={message.sourceLocale}>
|
|
{message.source || message.id}
|
|
</p>
|
|
</div>
|
|
|
|
<label className="i18n-message__field">
|
|
<span className="i18n-message__label">
|
|
{messages.messagePanel.translation}
|
|
<code>{locale}</code>
|
|
</span>
|
|
<textarea
|
|
aria-label={messages.messagePanel.translationFor(
|
|
message.id,
|
|
locale
|
|
)}
|
|
dir="auto"
|
|
lang={locale}
|
|
rows={1}
|
|
readOnly={!isEditable}
|
|
value={translation}
|
|
onChange={(event) => {
|
|
if (!isEditable) {
|
|
return
|
|
}
|
|
|
|
submittedTranslationRef.current = undefined
|
|
setTranslation(event.target.value)
|
|
setSaveState("idle")
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
isEditable &&
|
|
(event.metaKey || event.ctrlKey) &&
|
|
event.key === "Enter"
|
|
) {
|
|
event.preventDefault()
|
|
void handleSave()
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
{message.comments.length > 0 && (
|
|
<footer className="i18n-message__meta">
|
|
{message.comments.map((comment) => (
|
|
<span key={comment}>{comment}</span>
|
|
))}
|
|
</footer>
|
|
)}
|
|
|
|
{(isDirty || saveState !== "idle") && (
|
|
<div className="i18n-message__actions">
|
|
<span
|
|
aria-live="polite"
|
|
className="i18n-message__status"
|
|
data-state={saveState}
|
|
>
|
|
{saveState === "saved" && (
|
|
<>
|
|
<CheckIcon aria-hidden="true" />
|
|
{messages.messagePanel.saved}
|
|
</>
|
|
)}
|
|
{saveState === "error" && (
|
|
<>
|
|
<CircleAlertIcon aria-hidden="true" />
|
|
{messages.messagePanel.couldNotSave}
|
|
</>
|
|
)}
|
|
</span>
|
|
{isDirty && (
|
|
<>
|
|
<button type="button" onClick={handleReset}>
|
|
<RotateCcwIcon aria-hidden="true" />
|
|
{messages.messagePanel.reset}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="i18n-message__save"
|
|
disabled={saveState === "saving"}
|
|
onClick={() => void handleSave()}
|
|
>
|
|
<SaveIcon aria-hidden="true" />
|
|
{saveState === "saving"
|
|
? messages.messagePanel.saving
|
|
: messages.messagePanel.save}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</article>
|
|
)
|
|
}
|
|
|
|
export function MessagePanel({
|
|
className,
|
|
locale,
|
|
refreshToken,
|
|
repository: repositoryProp,
|
|
}: MessagePanelProps) {
|
|
const repositoryFromContext = useOptionalMessageRepository()
|
|
const repository = repositoryProp ?? repositoryFromContext
|
|
const { messages } = useDevtoolLocalization()
|
|
const [state, setState] = React.useState<LoadState>({ status: "loading" })
|
|
const [query, setQuery] = React.useState("")
|
|
const [missingOnly, setMissingOnly] = React.useState(false)
|
|
const [selectedNavigationKey, setSelectedNavigationKey] =
|
|
React.useState<string>()
|
|
const [openedNavigationKeys, setOpenedNavigationKeys] = React.useState<
|
|
readonly string[]
|
|
>([])
|
|
const loadRequestRef = React.useRef(0)
|
|
const previousLoadContextRef = React.useRef<{
|
|
locale: string
|
|
repository: MessageRepository | null
|
|
} | null>(null)
|
|
|
|
const loadMessages = React.useCallback(
|
|
async ({ preserveContent = false } = {}) => {
|
|
const requestId = loadRequestRef.current + 1
|
|
loadRequestRef.current = requestId
|
|
|
|
if (!repository) {
|
|
setState({
|
|
error: new Error(
|
|
"MessagePanel requires a MessageRepositoryProvider or a repository prop."
|
|
),
|
|
status: "error",
|
|
})
|
|
return
|
|
}
|
|
|
|
if (!preserveContent) {
|
|
setState({ status: "loading" })
|
|
}
|
|
|
|
try {
|
|
const [loadedMessages, packages] = await Promise.all([
|
|
repository.getMessages(locale),
|
|
repository.getPackages?.() ?? Promise.resolve([]),
|
|
])
|
|
|
|
if (requestId !== loadRequestRef.current) {
|
|
return
|
|
}
|
|
|
|
setState({ messages: loadedMessages, packages, status: "ready" })
|
|
} catch (error) {
|
|
if (requestId !== loadRequestRef.current) {
|
|
return
|
|
}
|
|
|
|
setState({
|
|
error:
|
|
error instanceof Error
|
|
? error
|
|
: new Error("Unable to load messages."),
|
|
status: "error",
|
|
})
|
|
}
|
|
},
|
|
[locale, repository]
|
|
)
|
|
|
|
React.useEffect(() => {
|
|
const previousLoadContext = previousLoadContextRef.current
|
|
const preserveContent =
|
|
previousLoadContext?.locale === locale &&
|
|
previousLoadContext.repository === repository
|
|
|
|
previousLoadContextRef.current = { locale, repository }
|
|
void loadMessages({ preserveContent })
|
|
}, [loadMessages, locale, refreshToken, repository])
|
|
|
|
const visibleMessages = React.useMemo(() => {
|
|
if (state.status !== "ready") {
|
|
return []
|
|
}
|
|
|
|
const normalizedQuery = query.trim().toLocaleLowerCase()
|
|
|
|
return state.messages.filter((message) => {
|
|
if (missingOnly && !message.missing) {
|
|
return false
|
|
}
|
|
|
|
return (
|
|
!normalizedQuery ||
|
|
getMessageSearchText(message).includes(normalizedQuery)
|
|
)
|
|
})
|
|
}, [missingOnly, query, state])
|
|
const visiblePackageGroups = React.useMemo(() => {
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
kind: DevtoolMessagePackage["kind"]
|
|
messages: DevtoolMessage[]
|
|
}
|
|
>()
|
|
|
|
if (state.status === "ready" && query.trim().length === 0 && !missingOnly) {
|
|
for (const packageDefinition of state.packages) {
|
|
groups.set(packageDefinition.name, {
|
|
kind: packageDefinition.kind,
|
|
messages: [],
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const message of visibleMessages) {
|
|
const group = groups.get(message.packageName)
|
|
|
|
if (group) {
|
|
group.messages.push(message)
|
|
} else {
|
|
groups.set(message.packageName, {
|
|
kind: "dependency",
|
|
messages: [message],
|
|
})
|
|
}
|
|
}
|
|
|
|
const packageGroups = Array.from(groups, ([packageName, group]) => ({
|
|
kind: group.kind,
|
|
messages: group.messages,
|
|
missingCount: group.messages.filter((message) => message.missing).length,
|
|
packageName,
|
|
}))
|
|
packageGroups.sort(
|
|
(left, right) =>
|
|
Number(right.kind === "application") -
|
|
Number(left.kind === "application") ||
|
|
left.packageName.localeCompare(right.packageName)
|
|
)
|
|
return packageGroups
|
|
}, [missingOnly, query, state, visibleMessages])
|
|
const navigationModel = React.useMemo(
|
|
() => createMessageNavigationModel(visiblePackageGroups),
|
|
[visiblePackageGroups]
|
|
)
|
|
const navigationSelections = React.useMemo(
|
|
() => Array.from(navigationModel.selections.values()),
|
|
[navigationModel]
|
|
)
|
|
const activeSelection =
|
|
(selectedNavigationKey
|
|
? navigationModel.selections.get(selectedNavigationKey)
|
|
: undefined) ??
|
|
navigationSelections.find((selection) => selection.messages.length > 0) ??
|
|
navigationSelections[0]
|
|
const activeNavigationKey = activeSelection?.key
|
|
|
|
React.useEffect(() => {
|
|
if (!activeNavigationKey) {
|
|
return
|
|
}
|
|
|
|
setOpenedNavigationKeys((current) =>
|
|
current.includes(activeNavigationKey)
|
|
? current
|
|
: [...current, activeNavigationKey]
|
|
)
|
|
}, [activeNavigationKey])
|
|
|
|
const openedSelections = React.useMemo(() => {
|
|
const activeKeys =
|
|
activeNavigationKey && !openedNavigationKeys.includes(activeNavigationKey)
|
|
? [...openedNavigationKeys, activeNavigationKey]
|
|
: openedNavigationKeys
|
|
|
|
return activeKeys.flatMap((key) => {
|
|
const selection = navigationModel.selections.get(key)
|
|
return selection ? [selection] : []
|
|
})
|
|
}, [activeNavigationKey, navigationModel, openedNavigationKeys])
|
|
const missingMessageCount =
|
|
state.status === "ready"
|
|
? state.messages.filter((message) => message.missing).length
|
|
: 0
|
|
|
|
const updateMessage = React.useCallback(
|
|
async (message: DevtoolMessage, translation: string) => {
|
|
if (!repository) {
|
|
throw new Error("No message repository is available.")
|
|
}
|
|
|
|
if (!message.editable) {
|
|
throw new Error("This source message is read-only.")
|
|
}
|
|
|
|
const updatedMessage = await repository.updateMessage({
|
|
catalog: message.catalog,
|
|
id: message.id,
|
|
locale,
|
|
translation,
|
|
})
|
|
|
|
setState((current) => {
|
|
if (current.status !== "ready") {
|
|
return current
|
|
}
|
|
|
|
return {
|
|
messages: current.messages.map((item) =>
|
|
item.catalog === updatedMessage.catalog &&
|
|
item.id === updatedMessage.id
|
|
? updatedMessage
|
|
: item
|
|
),
|
|
packages: current.packages,
|
|
status: "ready",
|
|
}
|
|
})
|
|
},
|
|
[locale, repository]
|
|
)
|
|
|
|
const rootClassName = ["i18n-message-panel", className]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
const emptyStateMessage =
|
|
state.status !== "ready" || visibleMessages.length > 0
|
|
? undefined
|
|
: state.messages.length === 0
|
|
? messages.messagePanel.emptyCatalog
|
|
: query.trim().length > 0
|
|
? messages.messagePanel.noSearchResults
|
|
: missingOnly
|
|
? messages.messagePanel.noMissingMessages
|
|
: messages.messagePanel.noMatches
|
|
|
|
return (
|
|
<section className={rootClassName}>
|
|
<header className="i18n-message-panel__toolbar">
|
|
<label className="i18n-message-panel__search">
|
|
<span className="i18n-visually-hidden">
|
|
{messages.messagePanel.search}
|
|
</span>
|
|
<SearchIcon aria-hidden="true" />
|
|
<input
|
|
type="search"
|
|
placeholder={messages.messagePanel.search}
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
/>
|
|
{state.status === "ready" && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="i18n-message-panel__result-count"
|
|
>
|
|
{visibleMessages.length}
|
|
</span>
|
|
)}
|
|
</label>
|
|
<label className="i18n-message-panel__toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={missingOnly}
|
|
onChange={(event) => setMissingOnly(event.target.checked)}
|
|
/>
|
|
<span aria-hidden="true" className="i18n-message-panel__checkbox">
|
|
<CheckIcon />
|
|
</span>
|
|
<span>{messages.messagePanel.missingOnly}</span>
|
|
{state.status === "ready" && (
|
|
<span aria-hidden="true" className="i18n-message-panel__count">
|
|
{missingMessageCount}
|
|
</span>
|
|
)}
|
|
</label>
|
|
</header>
|
|
|
|
{state.status === "loading" ? (
|
|
<div className="i18n-message-panel__state" role="status">
|
|
{messages.messagePanel.loading(locale)}
|
|
</div>
|
|
) : state.status === "error" ? (
|
|
<div className="i18n-message-panel__state" role="alert">
|
|
<p>{state.error.message}</p>
|
|
<button type="button" onClick={() => void loadMessages()}>
|
|
{messages.messagePanel.retry}
|
|
</button>
|
|
</div>
|
|
) : visiblePackageGroups.length === 0 ? (
|
|
<div className="i18n-message-panel__state">{emptyStateMessage}</div>
|
|
) : (
|
|
<div className="i18n-message-workspace">
|
|
<MessageNavigation
|
|
model={navigationModel}
|
|
selectedKey={activeNavigationKey}
|
|
onSelect={setSelectedNavigationKey}
|
|
/>
|
|
<section
|
|
aria-label={messages.messagePanel.messageEditor}
|
|
className="i18n-message-workspace__editor"
|
|
>
|
|
{activeSelection ? (
|
|
openedSelections.map((selection) => {
|
|
return (
|
|
<React.Activity
|
|
key={selection.key}
|
|
mode={
|
|
selection.key === activeNavigationKey
|
|
? "visible"
|
|
: "hidden"
|
|
}
|
|
name={selection.label}
|
|
>
|
|
{selection.messages.length > 0 ? (
|
|
<div className="i18n-message-workspace__editor-list">
|
|
{selection.messages.map((message) => (
|
|
<MessageEditor
|
|
key={`${message.catalog}:${message.id}`}
|
|
locale={locale}
|
|
message={message}
|
|
onSave={(translation) =>
|
|
updateMessage(message, translation)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="i18n-message-workspace__empty">
|
|
{visibleMessages.length === 0
|
|
? messages.messagePanel.emptyPackage
|
|
: messages.messagePanel.selectMessage}
|
|
</div>
|
|
)}
|
|
</React.Activity>
|
|
)
|
|
})
|
|
) : (
|
|
<div className="i18n-message-workspace__empty">
|
|
{messages.messagePanel.selectMessage}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|