diff --git a/.prettierignore b/.prettierignore index fd1cce2f19d..a830f045cb2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/client/.gitignore b/client/.gitignore index a832737557c..b1a21ca72a7 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -14,6 +14,7 @@ static/curriculum-data # Generated config config/browser-scripts/*.json i18n/locales/**/trending.json +i18n/locales/**/search-bar.json # Config diff --git a/client/i18n/config.js b/client/i18n/config.js index e52f23b6c5e..7b26253d69c 100644 --- a/client/i18n/config.js +++ b/client/i18n/config.js @@ -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 diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 111a3fcb462..fcedea63ba7 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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.", diff --git a/client/package.json b/client/package.json index c3f1ab09902..1dc7b98d3f0 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/search/searchBar/search-bar-optimized.tsx b/client/src/components/search/searchBar/search-bar-optimized.tsx index 511c3fd26ca..5b2db0f5de9 100644 --- a/client/src/components/search/searchBar/search-bar-optimized.tsx +++ b/client/src/components/search/searchBar/search-bar-optimized.tsx @@ -9,7 +9,12 @@ const SearchBarOptimized = ({ innerRef }: Pick): 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(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} diff --git a/client/src/components/search/searchBar/search-bar.tsx b/client/src/components/search/searchBar/search-bar.tsx index e1dce3ebea6..5e94c6dc1fc 100644 --- a/client/src/components/search/searchBar/search-bar.tsx +++ b/client/src/components/search/searchBar/search-bar.tsx @@ -201,6 +201,12 @@ export class SearchBar extends Component { 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 ( @@ -223,7 +229,7 @@ export class SearchBar extends Component { translations={{ submitTitle: t('icons.magnifier'), resetTitle: t('icons.input-reset'), - placeholder: t('search.placeholder') + placeholder: searchPlaceholder }} onFocus={this.handleFocus} /> diff --git a/client/tools/generate-search-placeholder.test.ts b/client/tools/generate-search-placeholder.test.ts new file mode 100644 index 00000000000..527bd879a14 --- /dev/null +++ b/client/tools/generate-search-placeholder.test.ts @@ -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); + }); + }); + } +}); diff --git a/client/tools/generate-search-placeholder.ts b/client/tools/generate-search-placeholder.ts new file mode 100644 index 00000000000..36cd69266bd --- /dev/null +++ b/client/tools/generate-search-placeholder.ts @@ -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' }); diff --git a/e2e/search-bar-optimized.spec.ts b/e2e/search-bar-optimized.spec.ts index 24ba27d0e76..b83909cb660 100644 --- a/e2e/search-bar-optimized.spec.ts +++ b/e2e/search-bar-optimized.spec.ts @@ -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 ); }); diff --git a/e2e/search-bar.spec.ts b/e2e/search-bar.spec.ts index 29ea02b1763..7efab8417fc 100644 --- a/e2e/search-bar.spec.ts +++ b/e2e/search-bar.spec.ts @@ -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' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27e2f0c11ff..70fb5ef6090 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: