308 lines
8.4 KiB
TypeScript
308 lines
8.4 KiB
TypeScript
|
|
import * as React from "react"
|
||
|
|
|
||
|
|
import { useOptionalMessageRepository } from "./message-repository"
|
||
|
|
import type { DevtoolMessage, MessageRepository } from "./types"
|
||
|
|
import "./message-panel.css"
|
||
|
|
|
||
|
|
type LoadState =
|
||
|
|
| { status: "loading" }
|
||
|
|
| { error: Error; status: "error" }
|
||
|
|
| { messages: readonly DevtoolMessage[]; status: "ready" }
|
||
|
|
|
||
|
|
export interface MessagePanelProps {
|
||
|
|
className?: string
|
||
|
|
locale: string
|
||
|
|
repository?: MessageRepository
|
||
|
|
}
|
||
|
|
|
||
|
|
function getMessageSearchText(message: DevtoolMessage) {
|
||
|
|
return [
|
||
|
|
message.id,
|
||
|
|
message.source,
|
||
|
|
message.translation,
|
||
|
|
...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 [translation, setTranslation] = React.useState(message.translation)
|
||
|
|
const [saveState, setSaveState] = React.useState<
|
||
|
|
"idle" | "saving" | "saved" | "error"
|
||
|
|
>("idle")
|
||
|
|
const submittedTranslationRef = React.useRef<string | undefined>(undefined)
|
||
|
|
const isDirty = translation !== message.translation
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
setTranslation(message.translation)
|
||
|
|
|
||
|
|
if (submittedTranslationRef.current === message.translation) {
|
||
|
|
submittedTranslationRef.current = undefined
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
setSaveState("idle")
|
||
|
|
}, [message.translation])
|
||
|
|
|
||
|
|
const handleSave = async () => {
|
||
|
|
if (!isDirty || saveState === "saving") {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
setSaveState("saving")
|
||
|
|
submittedTranslationRef.current = translation
|
||
|
|
|
||
|
|
try {
|
||
|
|
await onSave(translation)
|
||
|
|
setSaveState("saved")
|
||
|
|
} catch {
|
||
|
|
submittedTranslationRef.current = undefined
|
||
|
|
setSaveState("error")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<article
|
||
|
|
className="i18n-message"
|
||
|
|
data-missing={message.missing || undefined}
|
||
|
|
data-obsolete={message.obsolete || undefined}
|
||
|
|
>
|
||
|
|
<header className="i18n-message__header">
|
||
|
|
<code className="i18n-message__id">{message.id}</code>
|
||
|
|
<span className="i18n-message__catalog">{message.catalog}</span>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div className="i18n-message__source">
|
||
|
|
<span className="i18n-message__label">Source</span>
|
||
|
|
<p>{message.source || message.id}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<label className="i18n-message__field">
|
||
|
|
<span className="i18n-message__label">{locale}</span>
|
||
|
|
<textarea
|
||
|
|
aria-label={`${message.id} translation for ${locale}`}
|
||
|
|
value={translation}
|
||
|
|
onChange={(event) => {
|
||
|
|
submittedTranslationRef.current = undefined
|
||
|
|
setTranslation(event.target.value)
|
||
|
|
setSaveState("idle")
|
||
|
|
}}
|
||
|
|
onKeyDown={(event) => {
|
||
|
|
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||
|
|
event.preventDefault()
|
||
|
|
void handleSave()
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
|
||
|
|
{(message.comments.length > 0 || message.origins.length > 0) && (
|
||
|
|
<footer className="i18n-message__meta">
|
||
|
|
{message.comments.map((comment) => (
|
||
|
|
<span key={comment}>{comment}</span>
|
||
|
|
))}
|
||
|
|
{message.origins.map((origin) => (
|
||
|
|
<span key={`${origin.file}:${origin.line ?? ""}`}>
|
||
|
|
{origin.file}
|
||
|
|
{origin.line ? `:${origin.line}` : ""}
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</footer>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="i18n-message__actions">
|
||
|
|
<span aria-live="polite" className="i18n-message__status">
|
||
|
|
{saveState === "saving" && "Saving…"}
|
||
|
|
{saveState === "saved" && "Saved"}
|
||
|
|
{saveState === "error" && "Could not save"}
|
||
|
|
</span>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
disabled={!isDirty || saveState === "saving"}
|
||
|
|
onClick={() => void handleSave()}
|
||
|
|
>
|
||
|
|
Save
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function MessagePanel({
|
||
|
|
className,
|
||
|
|
locale,
|
||
|
|
repository: repositoryProp,
|
||
|
|
}: MessagePanelProps) {
|
||
|
|
const repositoryFromContext = useOptionalMessageRepository()
|
||
|
|
const repository = repositoryProp ?? repositoryFromContext
|
||
|
|
const [state, setState] = React.useState<LoadState>({ status: "loading" })
|
||
|
|
const [query, setQuery] = React.useState("")
|
||
|
|
const [missingOnly, setMissingOnly] = React.useState(false)
|
||
|
|
const loadRequestRef = React.useRef(0)
|
||
|
|
|
||
|
|
const loadMessages = React.useCallback(async () => {
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
setState({ status: "loading" })
|
||
|
|
|
||
|
|
try {
|
||
|
|
const messages = await repository.getMessages(locale)
|
||
|
|
|
||
|
|
if (requestId !== loadRequestRef.current) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
setState({ messages, 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(() => {
|
||
|
|
void loadMessages()
|
||
|
|
}, [loadMessages])
|
||
|
|
|
||
|
|
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 updateMessage = React.useCallback(
|
||
|
|
async (message: DevtoolMessage, translation: string) => {
|
||
|
|
if (!repository) {
|
||
|
|
throw new Error("No message repository is available.")
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
),
|
||
|
|
status: "ready",
|
||
|
|
}
|
||
|
|
})
|
||
|
|
},
|
||
|
|
[locale, repository]
|
||
|
|
)
|
||
|
|
|
||
|
|
const rootClassName = ["i18n-message-panel", className]
|
||
|
|
.filter(Boolean)
|
||
|
|
.join(" ")
|
||
|
|
|
||
|
|
return (
|
||
|
|
<section className={rootClassName}>
|
||
|
|
<header className="i18n-message-panel__toolbar">
|
||
|
|
<label className="i18n-message-panel__search">
|
||
|
|
<span className="i18n-visually-hidden">Search messages</span>
|
||
|
|
<input
|
||
|
|
type="search"
|
||
|
|
placeholder="Search messages"
|
||
|
|
value={query}
|
||
|
|
onChange={(event) => setQuery(event.target.value)}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<label className="i18n-message-panel__toggle">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={missingOnly}
|
||
|
|
onChange={(event) => setMissingOnly(event.target.checked)}
|
||
|
|
/>
|
||
|
|
Missing only
|
||
|
|
</label>
|
||
|
|
{state.status === "ready" && (
|
||
|
|
<output className="i18n-message-panel__count">
|
||
|
|
{visibleMessages.length} / {state.messages.length}
|
||
|
|
</output>
|
||
|
|
)}
|
||
|
|
</header>
|
||
|
|
|
||
|
|
{state.status === "loading" ? (
|
||
|
|
<div className="i18n-message-panel__state" role="status">
|
||
|
|
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()}>
|
||
|
|
Retry
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
) : visibleMessages.length === 0 ? (
|
||
|
|
<div className="i18n-message-panel__state">
|
||
|
|
No messages match the current filters.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="i18n-message-panel__list">
|
||
|
|
{visibleMessages.map((message) => (
|
||
|
|
<MessageEditor
|
||
|
|
key={`${message.catalog}:${message.id}`}
|
||
|
|
locale={locale}
|
||
|
|
message={message}
|
||
|
|
onSave={(translation) => updateMessage(message, translation)}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
)
|
||
|
|
}
|