Files
simple-react-app-kit/packages/i18n/src/devtool/message-panel.tsx
T

616 lines
18 KiB
TypeScript
Raw Normal View History

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