Skip to content

Commit 4370cbe

Browse files
authored
feat: allow filtering by locale in the translations page (#754)
1 parent e3d589f commit 4370cbe

File tree

5 files changed

+211
-5
lines changed

5 files changed

+211
-5
lines changed

.changeset/khaki-lemons-report.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@blinkk/root-cms': patch
3+
---
4+
5+
feat: allow filtering by locale in the translations page (#754)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
// This file is autogenerated by update-root-version.mjs.
2-
export const ROOT_VERSION = '2.2.1-alpha.7';
2+
export const ROOT_VERSION = '2.2.1';
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import {useState, useEffect, useCallback} from 'preact/hooks';
2+
3+
interface UseQueryParamOptions<T> {
4+
/** Whether to replace history entry instead of pushing new one. */
5+
replace?: boolean;
6+
/** Custom serialization function for complex values. */
7+
serialize?: (value: T) => string;
8+
/** Custom deserialization function for complex values. */
9+
deserialize?: (value: string) => T;
10+
}
11+
12+
type QueryParamSetter<T> = (value: T) => void;
13+
type QueryParamHookReturn<T> = [T, QueryParamSetter<T>];
14+
15+
/**
16+
* Hook that synchronizes state with a URL query parameter.
17+
*/
18+
export function useQueryParam<T = string>(
19+
paramName: string,
20+
defaultValue: T,
21+
options: UseQueryParamOptions<T> = {}
22+
): QueryParamHookReturn<T> {
23+
const {
24+
replace = false,
25+
serialize = (value: T) => String(value),
26+
deserialize = (value: string) => value as T,
27+
} = options;
28+
29+
// Get initial value from URL.
30+
const getInitialValue = useCallback((): T => {
31+
const urlParams = new URLSearchParams(window.location.search);
32+
const paramValue = urlParams.get(paramName);
33+
34+
return paramValue !== null ? deserialize(paramValue) : defaultValue;
35+
}, [paramName, defaultValue, deserialize]);
36+
37+
const [value, setValue] = useState(getInitialValue);
38+
39+
// Update state when URL changes (e.g., browser back/forward).
40+
useEffect(() => {
41+
const handlePopState = () => {
42+
const urlParams = new URLSearchParams(window.location.search);
43+
const paramValue = urlParams.get(paramName);
44+
const newValue =
45+
paramValue !== null ? deserialize(paramValue) : defaultValue;
46+
setValue(newValue);
47+
};
48+
49+
window.addEventListener('popstate', handlePopState);
50+
return () => window.removeEventListener('popstate', handlePopState);
51+
}, [paramName, defaultValue, deserialize]);
52+
53+
// Function to update both state and URL.
54+
const updateParam = useCallback(
55+
(newValue: T) => {
56+
setValue(newValue);
57+
58+
const url = new URL(window.location.href);
59+
const serializedValue = serialize(newValue);
60+
61+
if (serializedValue === String(defaultValue) || serializedValue === '') {
62+
// Remove parameter if it's the default value or empty.
63+
url.searchParams.delete(paramName);
64+
} else {
65+
url.searchParams.set(paramName, serializedValue);
66+
}
67+
68+
const method = replace ? 'replaceState' : 'pushState';
69+
window.history[method]({}, '', url.toString());
70+
},
71+
[paramName, defaultValue, serialize, replace]
72+
);
73+
74+
return [value, updateParam];
75+
}
76+
77+
export function useStringParam(
78+
paramName: string,
79+
defaultValue: string = ''
80+
): QueryParamHookReturn<string> {
81+
return useQueryParam(paramName, defaultValue);
82+
}
83+
84+
export function useNumberParam(
85+
paramName: string,
86+
defaultValue: number = 0
87+
): QueryParamHookReturn<number> {
88+
return useQueryParam(paramName, defaultValue, {
89+
serialize: (value: number) => String(value),
90+
deserialize: (value: string) => {
91+
const num = Number(value);
92+
return isNaN(num) ? defaultValue : num;
93+
},
94+
});
95+
}
96+
97+
export function useBooleanParam(
98+
paramName: string,
99+
defaultValue: boolean = false
100+
): QueryParamHookReturn<boolean> {
101+
return useQueryParam(paramName, defaultValue, {
102+
serialize: (value: boolean) => (value ? 'true' : 'false'),
103+
deserialize: (value: string) => value === 'true',
104+
});
105+
}
106+
107+
export function useArrayParam(
108+
paramName: string,
109+
defaultValue: string[] = []
110+
): QueryParamHookReturn<string[]> {
111+
return useQueryParam(paramName, defaultValue, {
112+
serialize: (value: string[]) =>
113+
Array.isArray(value) ? value.join(',') : '',
114+
deserialize: (value: string) =>
115+
value ? value.split(',').filter(Boolean) : defaultValue,
116+
});
117+
}
118+
119+
export function useJSONParam<T = Record<string, unknown>>(
120+
paramName: string,
121+
defaultValue: T
122+
): QueryParamHookReturn<T> {
123+
return useQueryParam(paramName, defaultValue, {
124+
serialize: (value: T) => {
125+
try {
126+
return JSON.stringify(value);
127+
} catch {
128+
return JSON.stringify(defaultValue);
129+
}
130+
},
131+
deserialize: (value: string) => {
132+
try {
133+
return JSON.parse(value) as T;
134+
} catch {
135+
return defaultValue;
136+
}
137+
},
138+
});
139+
}

packages/root-cms/ui/pages/DocTranslationsPage/DocTranslationsPage.css

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@
7777
.DocTranslationsPage__Table th {
7878
border: 1px solid var(--color-border);
7979
background: #F8F9FA;
80-
padding: 4px 10px;
8180
text-align: left;
8281
}
8382

@@ -93,8 +92,31 @@
9392
box-sizing: border-box;
9493
}
9594

95+
.DocTranslationsPage__Table__sourceCellHeader {
96+
position: sticky;
97+
left: 0;
98+
z-index: 1;
99+
}
100+
101+
.DocTranslationsPage__Table__headerLabel {
102+
padding: 4px 10px;
103+
display: block;
104+
width: 100%;
105+
}
106+
107+
.DocTranslationsPage__Table__localeHeader {
108+
cursor: pointer;
109+
position: relative;
110+
}
111+
112+
.DocTranslationsPage__Table__headerTooltip {
113+
width: 100%;
114+
}
115+
96116
.DocTranslationsPage__Table__sourceCellWrap {
97117
background: #F8F9FA;
118+
position: sticky;
119+
left: 0;
98120
}
99121

100122
.DocTranslationsPage__Table__sourceCell {

packages/root-cms/ui/pages/DocTranslationsPage/DocTranslationsPage.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@tabler/icons-preact';
99
import {useEffect, useMemo, useRef, useState} from 'preact/hooks';
1010
import {Heading} from '../../components/Heading/Heading.js';
11+
import {useArrayParam} from '../../hooks/useQueryParam.js';
1112
import {Layout} from '../../layout/Layout.js';
1213
import {joinClassNames} from '../../utils/classes.js';
1314
import {
@@ -44,7 +45,8 @@ export function DocTranslationsPage(props: DocTranslationsPageProps) {
4445
const docId = `${collection}/${slug}`;
4546

4647
const i18nConfig = window.__ROOT_CTX.rootConfig.i18n || {};
47-
const i18nLocales = i18nConfig.locales || ['en'];
48+
const defaultLocales = i18nConfig.locales || [];
49+
const [i18nLocales, setI18nLocales] = useArrayParam('locale', defaultLocales);
4850

4951
async function init() {
5052
try {
@@ -112,6 +114,17 @@ export function DocTranslationsPage(props: DocTranslationsPageProps) {
112114
setSaving(false);
113115
}
114116

117+
/** Toggles the locale in the URL and updates the state accordingly. */
118+
function toggleLocale(locale: string) {
119+
if (i18nLocales.length === defaultLocales.length) {
120+
// Show only the selected locale.
121+
setI18nLocales([locale]);
122+
} else {
123+
// Show all locales.
124+
setI18nLocales(defaultLocales);
125+
}
126+
}
127+
115128
return (
116129
<Layout>
117130
<div className="DocTranslationsPage">
@@ -177,6 +190,7 @@ export function DocTranslationsPage(props: DocTranslationsPageProps) {
177190
translationsMap={translationsMap}
178191
onChange={onChange}
179192
changesMap={changesMap}
193+
onSelectLocale={(locale) => toggleLocale(locale)}
180194
/>
181195
)}
182196
</div>
@@ -221,10 +235,13 @@ interface DocTranslationsPageTableProps {
221235
sourceStrings: string[];
222236
translationsMap: TranslationsMap;
223237
onChange: (source: string, locale: string, translation: string) => void;
238+
onSelectLocale: (locale: string) => void;
224239
changesMap: Record<string, Translation>;
225240
}
226241

227242
DocTranslationsPage.Table = (props: DocTranslationsPageTableProps) => {
243+
const i18nConfig = window.__ROOT_CTX.rootConfig.i18n || {};
244+
const allLocales = i18nConfig.locales || [];
228245
const sourceToTranslationsMap = useMemo(() => {
229246
const results: {[source: string]: Record<string, string>} = {};
230247
Object.values(props.translationsMap).forEach(
@@ -249,9 +266,32 @@ DocTranslationsPage.Table = (props: DocTranslationsPageTableProps) => {
249266
<table className="DocTranslationsPage__Table">
250267
<thead>
251268
<tr>
252-
<th>source</th>
269+
<th className="DocTranslationsPage__Table__sourceCellHeader">
270+
<div className="DocTranslationsPage__Table__headerLabel">
271+
source
272+
</div>
273+
</th>
253274
{props.locales.map((locale) => (
254-
<th key={locale}>{locale}</th>
275+
<th
276+
key={locale}
277+
onClick={() => props.onSelectLocale(locale)}
278+
className="DocTranslationsPage__Table__localeHeader"
279+
>
280+
<Tooltip
281+
className="DocTranslationsPage__Table__headerTooltip"
282+
placement="start"
283+
withArrow
284+
label={
285+
props.locales.length === allLocales.length
286+
? `Show only ${locale}`
287+
: 'Show all locales'
288+
}
289+
>
290+
<div className="DocTranslationsPage__Table__headerLabel">
291+
{locale}
292+
</div>
293+
</Tooltip>
294+
</th>
255295
))}
256296
</tr>
257297
</thead>

0 commit comments

Comments
 (0)