feat: dynamically generate search placeholder (#56276)

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit is contained in:
Kristofer Koishigawa
2024-10-05 01:42:45 +09:00
committed by GitHub
parent 45fb3774bd
commit a378208d4d
12 changed files with 353 additions and 25 deletions

View File

@@ -2,6 +2,7 @@
**/*fixtures*
api-server/lib
client/**/trending.json
client/**/search-bar.json
client/config/*.json
client/config/browser-scripts/*.json
client/static

1
client/.gitignore vendored
View File

@@ -14,6 +14,7 @@ static/curriculum-data
# Generated config
config/browser-scripts/*.json
i18n/locales/**/trending.json
i18n/locales/**/search-bar.json
# Config

View File

@@ -53,6 +53,13 @@ i18n.use(initReactI18next).init({
if (clientLocale !== 'english') {
module.exports = require('./locales/' + clientLocale + '/links.json');
}
`,
'search-bar': preval`
const envData = require('../config/env.json');
const { clientLocale } = envData;
if (clientLocale !== 'english') {
module.exports = require('./locales/' + clientLocale + '/search-bar.json');
}
`
},
en: {
@@ -60,10 +67,11 @@ i18n.use(initReactI18next).init({
trending: preval`module.exports = require('./locales/english/trending.json')`,
intro: preval`module.exports = require('./locales/english/intro.json')`,
metaTags: preval`module.exports = require('./locales/english/meta-tags.json')`,
links: preval`module.exports = require('./locales/english/links.json')`
links: preval`module.exports = require('./locales/english/links.json')`,
'search-bar': preval`module.exports = require('./locales/english/search-bar.json')`
}
},
ns: ['translations', 'trending', 'intro', 'metaTags', 'links'],
ns: ['translations', 'trending', 'intro', 'metaTags', 'links', 'search-bar'],
defaultNS: 'translations',
returnObjects: true,
// Uncomment the next line for debug logging

View File

@@ -692,7 +692,10 @@
},
"search": {
"label": "Search",
"placeholder": "Search 10,700+ tutorials",
"placeholder": {
"default": "Search our tutorials",
"numbered": "Search {{ roundedTotalRecords }}+ tutorials"
},
"see-results": "See all results for {{searchQuery}}",
"no-tutorials": "No tutorials found",
"try": "Looking for something? Try the search bar on this page.",

View File

@@ -23,9 +23,10 @@
"build": "NODE_OPTIONS=\"--max-old-space-size=7168\" gatsby build --prefix-paths",
"build:scripts": "pnpm run -F=browser-scripts build",
"clean": "gatsby clean",
"common-setup": "pnpm -w run create:shared && pnpm run create:env && pnpm run create:trending",
"common-setup": "pnpm -w run create:shared && pnpm run create:env && pnpm run create:trending && pnpm run create:search-placeholder",
"create:env": "DEBUG=fcc:* ts-node ./tools/create-env.ts",
"create:trending": "ts-node ./tools/download-trending.ts",
"create:search-placeholder": "ts-node ./tools/generate-search-placeholder",
"predevelop": "pnpm run common-setup && pnpm run build:scripts --env development",
"develop": "NODE_OPTIONS=\"--max-old-space-size=7168\" gatsby develop --inspect=9230",
"lint": "ts-node ./i18n/schema-validation.ts",
@@ -159,6 +160,7 @@
"core-js": "2.6.12",
"dotenv": "16.4.5",
"gatsby-plugin-webpack-bundle-analyser-v2": "1.1.32",
"i18next-fs-backend": "2.3.2",
"jest-json-schema-extended": "1.0.1",
"joi": "17.12.2",
"js-yaml": "4.1.0",

View File

@@ -9,7 +9,12 @@ const SearchBarOptimized = ({
innerRef
}: Pick<SearchBarProps, 'innerRef'>): JSX.Element => {
const { t } = useTranslation();
const placeholder = t('search.placeholder');
// TODO: Refactor this fallback when all translation files are synced
const searchPlaceholder = t('search-bar:placeholder').startsWith(
'search.placeholder.'
)
? t('search.placeholder')
: t('search-bar:placeholder');
const searchUrl = searchPageUrl;
const [value, setValue] = useState('');
const inputElementRef = useRef<HTMLInputElement>(null);
@@ -50,7 +55,7 @@ const SearchBarOptimized = ({
className='ais-SearchBox-input'
maxLength={512}
onChange={onChange}
placeholder={placeholder}
placeholder={searchPlaceholder}
spellCheck='false'
type='search'
value={value}

View File

@@ -201,6 +201,12 @@ export class SearchBar extends Component<SearchBarProps, SearchBarState> {
render(): JSX.Element {
const { isDropdownEnabled, isSearchFocused, innerRef, t } = this.props;
const { index } = this.state;
// TODO: Refactor this fallback when all translation files are synced
const searchPlaceholder = t('search-bar:placeholder').startsWith(
'search.placeholder.'
)
? t('search.placeholder')
: t('search-bar:placeholder');
return (
<WithInstantSearch>
@@ -223,7 +229,7 @@ export class SearchBar extends Component<SearchBarProps, SearchBarState> {
translations={{
submitTitle: t('icons.magnifier'),
resetTitle: t('icons.input-reset'),
placeholder: t('search.placeholder')
placeholder: searchPlaceholder
}}
onFocus={this.handleFocus}
/>

View File

@@ -0,0 +1,175 @@
import { clientLocale } from '../config/env.json';
import {
convertToLocalizedString,
generateSearchPlaceholder,
roundDownToNearestHundred
} from './generate-search-placeholder';
describe('Search bar placeholder tests:', () => {
describe('Number rounding', () => {
test('Numbers less than 100 return 0', () => {
const testArr = [0, 1, 50, 99];
testArr.forEach(num => {
expect(roundDownToNearestHundred(num)).toEqual(0);
});
});
test('Numbers greater than 100 return a number rounded down to the nearest 100', () => {
const testArr = [
{
num: 100,
expected: 100
},
{
num: 101,
expected: 100
},
{
num: 199,
expected: 100
},
{
num: 999,
expected: 900
},
{
num: 1000,
expected: 1000
},
{
num: 1001,
expected: 1000
},
{
num: 1999,
expected: 1900
},
{
num: 10000,
expected: 10000
},
{
num: 10001,
expected: 10000
},
{
num: 19999,
expected: 19900
}
];
testArr.forEach(obj => {
expect(roundDownToNearestHundred(obj.num)).toEqual(obj.expected);
});
});
});
describe('Number formatting', () => {
test('Numbers are converted to the correct decimal or comma format for each locale', () => {
const testArr = [
{
num: 100,
locale: 'en',
expected: '100'
},
{
num: 100,
locale: 'zh',
expected: '100'
},
{
num: 100,
locale: 'de',
expected: '100'
},
{
num: 1000,
locale: 'en',
expected: '1,000'
},
{
num: 1000,
locale: 'zh',
expected: '1,000'
},
{
num: 1000,
locale: 'de',
expected: '1.000'
},
{
num: 10000,
locale: 'en',
expected: '10,000'
},
{
num: 10000,
locale: 'zh',
expected: '10,000'
},
{
num: 10000,
locale: 'de',
expected: '10.000'
},
{
num: 100000,
locale: 'en',
expected: '100,000'
},
{
num: 100000,
locale: 'zh',
expected: '100,000'
},
{
num: 100000,
locale: 'de',
expected: '100.000'
}
];
testArr.forEach(obj => {
const { num, locale, expected } = obj;
expect(convertToLocalizedString(num, locale)).toEqual(expected);
});
});
});
// Note: Only test the English locale to prevent duplicate tests,
// and just to ensure the logic is working as expected.
if (clientLocale === 'english') {
describe('Placeholder strings', () => {
test('When the total number of hits is less than 100 the expected placeholder is generated', async () => {
const expected = 'Search our tutorials';
const placeholderText = await generateSearchPlaceholder({
mockRecordsNum: 99,
locale: 'english'
});
expect(placeholderText).toEqual(expected);
});
test('When the total number of hits is equal to 100 the expected placeholder is generated', async () => {
const placeholderText = await generateSearchPlaceholder({
mockRecordsNum: 100,
locale: 'english'
});
const expected = 'Search 100+ tutorials';
expect(placeholderText).toEqual(expected);
});
test('When the total number of hits is greater than 100 the expected placeholder is generated', async () => {
const placeholderText = await generateSearchPlaceholder({
mockRecordsNum: 11000,
locale: 'english'
});
const expected = 'Search 11,000+ tutorials';
expect(placeholderText).toEqual(expected);
});
});
}
});

View File

@@ -0,0 +1,115 @@
import { writeFileSync, readdirSync, lstatSync } from 'fs';
import { join, resolve } from 'path';
import algoliasearch from 'algoliasearch';
import i18n from 'i18next';
import backend from 'i18next-fs-backend';
import {
algoliaAppId,
algoliaAPIKey,
clientLocale,
environment
} from '../config/env.json';
import { newsIndex } from '../src/utils/algolia-locale-setup';
import { i18nextCodes } from '../../shared/config/i18n';
const i18nextCode = i18nextCodes[clientLocale as keyof typeof i18nextCodes];
i18n
.use(backend)
.init({
defaultNS: 'translations',
fallbackLng: 'en',
interpolation: {
escapeValue: false
},
initImmediate: false,
preload: readdirSync(join(__dirname, '../i18n/locales')).filter(
fileName => {
const joinedPath = join(join(__dirname, '../i18n/locales'), fileName);
const isDirectory = lstatSync(joinedPath).isDirectory();
return isDirectory;
}
),
lng: i18nextCode,
ns: ['translations'],
backend: {
loadPath: resolve(
__dirname,
`../i18n/locales/${clientLocale}/translations.json`
)
}
})
.catch((error: Error) => {
throw Error(error.message);
});
const t = i18n.t.bind(i18n);
export const roundDownToNearestHundred = (num: number) =>
Math.floor(num / 100) * 100;
export const convertToLocalizedString = (num: number, ISOCode: string) =>
num.toLocaleString(ISOCode);
interface GenerateSearchPlaceholderOptions {
locale?: string;
mockRecordsNum?: number;
}
export const generateSearchPlaceholder = async (
options: GenerateSearchPlaceholderOptions = {}
) => {
const { locale, mockRecordsNum } = options;
let placeholderText = t('search.placeholder.default');
try {
let totalRecords = mockRecordsNum || 0;
if (!mockRecordsNum) {
const algoliaClient = algoliasearch(algoliaAppId, algoliaAPIKey);
const index = algoliaClient.initIndex(newsIndex);
const res = await index.search('');
totalRecords = res.nbHits;
}
const roundedTotalRecords = roundDownToNearestHundred(totalRecords);
if (roundedTotalRecords >= 100) {
placeholderText = i18n.t('search.placeholder.numbered', {
roundedTotalRecords: convertToLocalizedString(
roundedTotalRecords,
i18nextCode
)
});
}
} catch (err) {
if (environment === 'production') {
console.warn(`
----------------------------------------------------------
Warning: Could not get the total number of Algolia records
----------------------------------------------------------
Make sure that Algolia keys and index are set up correctly.
Falling back to the default search placeholder text.
----------------------------------------------------------
`);
}
}
writeFileSync(
resolve(
__dirname,
`../i18n/locales/${locale ? locale : clientLocale}/search-bar.json`
),
JSON.stringify({
placeholder: placeholderText
})
);
return placeholderText; // for testing
};
void generateSearchPlaceholder();
// TODO: remove the need to fallback to english once we're confident it's
// unnecessary (client/i18n/config.js will need all references to 'en' removing)
if (clientLocale !== 'english')
void generateSearchPlaceholder({ locale: 'english' });

View File

@@ -28,9 +28,11 @@ test.describe('Search bar optimized', () => {
const searchInput = await getSearchInput({ page, isMobile });
await expect(searchInput).toBeVisible();
// Because we're mocking Algolia requests, the placeholder
// should be the default one.
await expect(searchInput).toHaveAttribute(
'placeholder',
translations.search.placeholder
translations.search.placeholder.default
);
});

View File

@@ -73,9 +73,11 @@ test.describe('Search bar', () => {
const searchInput = await getSearchInput({ page, isMobile });
await expect(searchInput).toBeVisible();
// Because we're mocking Algolia requests, the placeholder
// should be the default one.
await expect(searchInput).toHaveAttribute(
'placeholder',
translations.search.placeholder
translations.search.placeholder.default
);
await expect(
page.getByRole('button', { name: 'Submit search terms' })

40
pnpm-lock.yaml generated
View File

@@ -795,6 +795,9 @@ importers:
gatsby-plugin-webpack-bundle-analyser-v2:
specifier: 1.1.32
version: 1.1.32(gatsby@3.15.0(@types/node@20.12.8)(babel-eslint@10.1.0(eslint@7.32.0))(eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@7.1.1(eslint@8.57.0)(typescript@5.4.5))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint-plugin-testing-library@3.9.0(eslint@7.32.0)(typescript@5.2.2))(react-dom@16.14.0(react@16.14.0))(react@16.14.0)(typescript@5.2.2))
i18next-fs-backend:
specifier: 2.3.2
version: 2.3.2
jest-json-schema-extended:
specifier: 1.0.1
version: 1.0.1
@@ -1125,13 +1128,13 @@ importers:
version: 4.17.12
babel-loader:
specifier: 8.3.0
version: 8.3.0(@babel/core@7.23.7)(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)))
version: 8.3.0(@babel/core@7.23.7)(webpack@5.90.3(webpack-cli@4.10.0))
chai:
specifier: 4.4.1
version: 4.4.1
copy-webpack-plugin:
specifier: 9.1.0
version: 9.1.0(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)))
version: 9.1.0(webpack@5.90.3(webpack-cli@4.10.0))
enzyme:
specifier: 3.11.0
version: 3.11.0
@@ -1158,7 +1161,7 @@ importers:
version: 0.12.5
webpack:
specifier: 5.90.3
version: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))
version: 5.90.3(webpack-cli@4.10.0)
webpack-cli:
specifier: 4.10.0
version: 4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)
@@ -8122,6 +8125,9 @@ packages:
hyphenate-style-name@1.0.4:
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
i18next-fs-backend@2.3.2:
resolution: {integrity: sha512-LIwUlkqDZnUI8lnUxBnEj8K/FrHQTT/Sc+1rvDm9E8YvvY5YxzoEAASNx+W5M9DfD5s77lI5vSAFWeTp26B/3Q==}
i18next@22.5.1:
resolution: {integrity: sha512-8TGPgM3pAD+VRsMtUMNknRz3kzqwp/gPALrWMsDnmC1mKqJwpWyooQRLMcbTwq8z8YwSmuj+ZYvc+xCuEpkssA==}
@@ -18258,7 +18264,7 @@ snapshots:
dependencies:
'@types/node': 20.8.0
tapable: 2.2.1
webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))
webpack: 5.90.3(webpack-cli@4.10.0)
transitivePeerDependencies:
- '@swc/core'
- esbuild
@@ -19000,9 +19006,9 @@ snapshots:
'@webassemblyjs/ast': 1.11.6
'@xtuc/long': 4.2.2
'@webpack-cli/configtest@1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)))':
'@webpack-cli/configtest@1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))(webpack@5.90.3(webpack-cli@4.10.0))':
dependencies:
webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))
webpack: 5.90.3(webpack-cli@4.10.0)
webpack-cli: 4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)
'@webpack-cli/info@1.5.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))':
@@ -19501,14 +19507,14 @@ snapshots:
schema-utils: 2.7.1
webpack: 5.90.3
babel-loader@8.3.0(@babel/core@7.23.7)(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))):
babel-loader@8.3.0(@babel/core@7.23.7)(webpack@5.90.3(webpack-cli@4.10.0)):
dependencies:
'@babel/core': 7.23.7
find-cache-dir: 3.3.2
loader-utils: 2.0.4
make-dir: 3.1.0
schema-utils: 2.7.1
webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))
webpack: 5.90.3(webpack-cli@4.10.0)
babel-plugin-add-module-exports@1.0.4: {}
@@ -20626,7 +20632,7 @@ snapshots:
copy-descriptor@0.1.1: {}
copy-webpack-plugin@9.1.0(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))):
copy-webpack-plugin@9.1.0(webpack@5.90.3(webpack-cli@4.10.0)):
dependencies:
fast-glob: 3.3.1
glob-parent: 6.0.2
@@ -20634,7 +20640,7 @@ snapshots:
normalize-path: 3.0.0
schema-utils: 3.3.0
serialize-javascript: 6.0.1
webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))
webpack: 5.90.3(webpack-cli@4.10.0)
core-js-compat@3.33.0:
dependencies:
@@ -23908,6 +23914,8 @@ snapshots:
hyphenate-style-name@1.0.4: {}
i18next-fs-backend@2.3.2: {}
i18next@22.5.1:
dependencies:
'@babel/runtime': 7.23.9
@@ -29426,14 +29434,14 @@ snapshots:
term-size@2.2.1: {}
terser-webpack-plugin@5.3.10(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))):
terser-webpack-plugin@5.3.10(webpack@5.90.3(webpack-cli@4.10.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 3.3.0
serialize-javascript: 6.0.1
terser: 5.28.1
webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))
webpack: 5.90.3(webpack-cli@4.10.0)
terser-webpack-plugin@5.3.10(webpack@5.90.3):
dependencies:
@@ -30308,7 +30316,7 @@ snapshots:
webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3):
dependencies:
'@discoveryjs/json-ext': 0.5.7
'@webpack-cli/configtest': 1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)))
'@webpack-cli/configtest': 1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))(webpack@5.90.3(webpack-cli@4.10.0))
'@webpack-cli/info': 1.5.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))
'@webpack-cli/serve': 1.7.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))
colorette: 2.0.20
@@ -30318,7 +30326,7 @@ snapshots:
import-local: 3.1.0
interpret: 2.2.0
rechoir: 0.7.1
webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))
webpack: 5.90.3(webpack-cli@4.10.0)
webpack-merge: 5.9.0
optionalDependencies:
webpack-bundle-analyzer: 4.10.1
@@ -30384,7 +30392,7 @@ snapshots:
- esbuild
- uglify-js
webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)):
webpack@5.90.3(webpack-cli@4.10.0):
dependencies:
'@types/eslint-scope': 3.7.5
'@types/estree': 1.0.5
@@ -30407,7 +30415,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.2.1
terser-webpack-plugin: 5.3.10(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)))
terser-webpack-plugin: 5.3.10(webpack@5.90.3(webpack-cli@4.10.0))
watchpack: 2.4.0
webpack-sources: 3.2.3
optionalDependencies: