@scrabble-solver/scrabble-solver 2.8.6 → 2.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +10 -10
  3. package/.next/cache/.tsbuildinfo +1 -1
  4. package/.next/cache/eslint/.cache_8dgz12 +1 -1
  5. package/.next/cache/next-server.js.nft.json +1 -1
  6. package/.next/cache/webpack/client-production/0.pack +0 -0
  7. package/.next/cache/webpack/client-production/index.pack +0 -0
  8. package/.next/cache/webpack/server-production/0.pack +0 -0
  9. package/.next/cache/webpack/server-production/index.pack +0 -0
  10. package/.next/next-server.js.nft.json +1 -1
  11. package/.next/prerender-manifest.json +1 -1
  12. package/.next/routes-manifest.json +1 -1
  13. package/.next/server/chunks/413.js +266 -110
  14. package/.next/server/chunks/{206.js → 429.js} +2 -4137
  15. package/.next/server/chunks/515.js +197 -91
  16. package/.next/server/chunks/{907.js → 911.js} +134 -367
  17. package/.next/server/chunks/939.js +218 -0
  18. package/.next/server/middleware-build-manifest.js +1 -1
  19. package/.next/server/pages/404.html +2 -2
  20. package/.next/server/pages/404.js.nft.json +1 -1
  21. package/.next/server/pages/500.html +2 -2
  22. package/.next/server/pages/_app.js.nft.json +1 -1
  23. package/.next/server/pages/api/dictionary/[locale]/[word].js +33 -17
  24. package/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
  25. package/.next/server/pages/api/solve.js +399 -56
  26. package/.next/server/pages/api/solve.js.nft.json +1 -1
  27. package/.next/server/pages/api/visit.js +3 -2
  28. package/.next/server/pages/api/visit.js.nft.json +1 -1
  29. package/.next/server/pages/index.html +3 -7
  30. package/.next/server/pages/index.js +12 -14
  31. package/.next/server/pages/index.js.nft.json +1 -1
  32. package/.next/server/pages/index.json +1 -1
  33. package/.next/static/chunks/615-d258f6c528c18622.js +1 -0
  34. package/.next/static/chunks/pages/{404-30c06e61d256c5b2.js → 404-8eb3ba4f0ba17e08.js} +1 -1
  35. package/.next/static/chunks/pages/_app-4a663fd3d5ca4524.js +1 -0
  36. package/.next/static/chunks/pages/index-1a9826d740cc8830.js +1 -0
  37. package/.next/static/css/180c6c26317ac90f.css +1 -0
  38. package/.next/static/css/751e8a14776d05d8.css +1 -0
  39. package/.next/static/z3J3qmq1nazbDv_ENIkCo/_buildManifest.js +1 -0
  40. package/.next/static/{VjSpyGDWyVaO0muz54q_j → z3J3qmq1nazbDv_ENIkCo}/_ssgManifest.js +0 -0
  41. package/.next/trace +41 -42
  42. package/package.json +9 -9
  43. package/src/api/index.ts +3 -9
  44. package/src/api/isBoardValid.ts +43 -0
  45. package/src/api/isCellValid.ts +26 -0
  46. package/src/api/isRowValid.ts +19 -0
  47. package/src/components/Board/components/Cell/Cell.module.scss +34 -9
  48. package/src/components/Board/components/Cell/Cell.tsx +23 -4
  49. package/src/components/Board/components/Cell/CellPure.tsx +29 -1
  50. package/src/components/Board/hooks/useGrid.ts +1 -0
  51. package/src/components/Dictionary/Dictionary.module.scss +20 -0
  52. package/src/components/Dictionary/Dictionary.tsx +40 -29
  53. package/src/components/Results/Cell.tsx +3 -2
  54. package/src/components/Results/Result.tsx +16 -6
  55. package/src/components/ResultsInput/ResultsInput.tsx +11 -3
  56. package/src/hooks/useIsTablet.ts +2 -2
  57. package/src/i18n/de.json +1 -0
  58. package/src/i18n/en.json +1 -0
  59. package/src/i18n/es.json +1 -0
  60. package/src/i18n/fr.json +1 -0
  61. package/src/i18n/pl.json +1 -0
  62. package/src/icons/Flag.svg +4 -0
  63. package/src/icons/Star.svg +4 -0
  64. package/src/icons/index.ts +2 -0
  65. package/src/lib/getRemainingTiles.ts +1 -1
  66. package/src/lib/index.ts +2 -1
  67. package/src/lib/isRegExp.ts +11 -0
  68. package/src/lib/isStringArray.ts +5 -0
  69. package/src/lib/sortResults.ts +5 -5
  70. package/src/pages/api/dictionary/[locale]/[word].ts +35 -11
  71. package/src/pages/api/solve.ts +39 -19
  72. package/src/pages/api/visit.ts +1 -0
  73. package/src/pages/index.module.scss +5 -11
  74. package/src/pages/index.tsx +5 -5
  75. package/src/sdk/{findWordDefinition.ts → findWordDefinitions.ts} +3 -3
  76. package/src/sdk/index.ts +1 -1
  77. package/src/state/rootReducer.ts +10 -1
  78. package/src/state/sagas.ts +32 -12
  79. package/src/state/selectors.ts +41 -7
  80. package/src/state/slices/cellFilterInitialState.ts +7 -0
  81. package/src/state/slices/cellFilterSlice.ts +24 -0
  82. package/src/state/slices/dictionaryInitialState.ts +3 -3
  83. package/src/state/slices/dictionarySlice.ts +4 -10
  84. package/src/state/slices/index.ts +2 -0
  85. package/src/types/index.ts +1 -0
  86. package/.next/static/VjSpyGDWyVaO0muz54q_j/_buildManifest.js +0 -1
  87. package/.next/static/chunks/56-e2797384ae4b0fc0.js +0 -1
  88. package/.next/static/chunks/pages/_app-5136d33b9b007fd7.js +0 -1
  89. package/.next/static/chunks/pages/index-13ea7770a65c69ee.js +0 -1
  90. package/.next/static/css/3159cfe62ff742a3.css +0 -1
  91. package/.next/static/css/729bb37fe8f9bee6.css +0 -1
  92. package/src/api/validateBoard.ts +0 -45
  93. package/src/api/validateCell.ts +0 -40
  94. package/src/api/validateCharacter.ts +0 -14
  95. package/src/api/validateCharacters.ts +0 -24
  96. package/src/api/validateConfigId.ts +0 -9
  97. package/src/api/validateLocale.ts +0 -15
  98. package/src/api/validateRow.ts +0 -17
  99. package/src/api/validateTile.ts +0 -21
  100. package/src/api/validateWord.ts +0 -11
  101. package/src/lib/isLocale.ts +0 -7
package/src/lib/index.ts CHANGED
@@ -15,8 +15,9 @@ export { default as getTotalRemainingTilesCount } from './getTotalRemainingTiles
15
15
  export { default as getTileSizes } from './getTileSizes';
16
16
  export { default as inverseDirection } from './inverseDirection';
17
17
  export { default as isCtrl } from './isCtrl';
18
- export { default as isLocale } from './isLocale';
19
18
  export { default as isMac } from './isMac';
19
+ export { default as isRegExp } from './isRegExp';
20
+ export { default as isStringArray } from './isStringArray';
20
21
  export { default as memoize } from './memoize';
21
22
  export { default as noop } from './noop';
22
23
  export { default as numberComparator } from './numberComparator';
@@ -0,0 +1,11 @@
1
+ const isRegExp = (value: string): boolean => {
2
+ try {
3
+ // eslint-disable-next-line no-new
4
+ new RegExp(value);
5
+ return true;
6
+ } catch {
7
+ return false;
8
+ }
9
+ };
10
+
11
+ export default isRegExp;
@@ -0,0 +1,5 @@
1
+ const isStringArray = (value: unknown): value is string[] => {
2
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
3
+ };
4
+
5
+ export default isStringArray;
@@ -6,13 +6,13 @@ import createKeyComparator from './createKeyComparator';
6
6
  import reverseComparator from './reverseComparator';
7
7
 
8
8
  const comparators: Record<ResultColumn, Comparator<Result>> = {
9
- [ResultColumn.BlanksCount]: createKeyComparator('numberOfBlanks'),
10
- [ResultColumn.ConsonantsCount]: createKeyComparator('numberOfConsonants'),
9
+ [ResultColumn.BlanksCount]: createKeyComparator('blanksCount'),
10
+ [ResultColumn.ConsonantsCount]: createKeyComparator('consonantsCount'),
11
11
  [ResultColumn.Points]: createKeyComparator('points'),
12
- [ResultColumn.TilesCount]: createKeyComparator('numberOfTiles'),
13
- [ResultColumn.VowelsCount]: createKeyComparator('numberOfVowels'),
12
+ [ResultColumn.TilesCount]: createKeyComparator('tilesCount'),
13
+ [ResultColumn.VowelsCount]: createKeyComparator('vowelsCount'),
14
14
  [ResultColumn.Word]: createKeyComparator('word'),
15
- [ResultColumn.WordsCount]: createKeyComparator('numberOfWords'),
15
+ [ResultColumn.WordsCount]: createKeyComparator('wordsCount'),
16
16
  };
17
17
 
18
18
  const sortResults = (
@@ -1,45 +1,69 @@
1
+ import { scrabble } from '@scrabble-solver/configs';
1
2
  import logger from '@scrabble-solver/logger';
2
- import { Locale } from '@scrabble-solver/types';
3
+ import { isLocale, Locale } from '@scrabble-solver/types';
3
4
  import { getWordDefinition } from '@scrabble-solver/word-definitions';
4
5
  import { NextApiRequest, NextApiResponse } from 'next';
5
6
 
6
- import { getServerLoggingData, validateLocale, validateWord } from 'api';
7
+ import { getServerLoggingData } from 'api';
7
8
 
8
9
  interface RequestData {
9
10
  locale: Locale;
10
- word: string;
11
+ words: string[];
11
12
  }
12
13
 
14
+ const MAXIMUM_WORDS_COUNT = scrabble['en-US'].maximumCharactersCount;
15
+
13
16
  const dictionary = async (request: NextApiRequest, response: NextApiResponse): Promise<void> => {
14
17
  const meta = getServerLoggingData(request);
15
18
 
16
19
  try {
17
- const { locale, word } = parseRequest(request);
20
+ const { locale, words } = parseRequest(request);
21
+
18
22
  logger.info('dictionary - request', {
19
23
  meta,
20
24
  payload: {
21
25
  locale,
22
- word,
26
+ words,
23
27
  },
24
28
  });
25
- const result = await getWordDefinition(locale, word);
26
- response.status(200).send(result.toJson());
29
+
30
+ const results = await Promise.all(words.map((word) => getWordDefinition(locale, word)));
31
+ response.status(200).send(results.map((result) => result.toJson()));
27
32
  } catch (error) {
28
33
  const message = error instanceof Error ? error.message : 'Unknown error';
29
34
  logger.error('dictionary - error', { error, meta });
30
35
  response.status(500).send({ error: 'Server error', message });
36
+ throw error;
31
37
  }
32
38
  };
33
39
 
34
40
  const parseRequest = (request: NextApiRequest): RequestData => {
35
41
  const { locale, word } = request.query;
36
42
 
37
- validateLocale(locale);
38
- validateWord(word);
43
+ if (!isLocale(locale)) {
44
+ throw new Error('Invalid "locale" parameter');
45
+ }
46
+
47
+ if (typeof word !== 'string' || word.length === 0) {
48
+ throw new Error('Invalid "word" parameter');
49
+ }
50
+
51
+ const words = Array.from(
52
+ new Set(
53
+ word
54
+ .split(',')
55
+ .map((part) => part.trim())
56
+ .filter(Boolean),
57
+ ),
58
+ );
59
+
60
+ if (words.length > MAXIMUM_WORDS_COUNT) {
61
+ throw new Error('Invalid "word" parameter');
62
+ }
39
63
 
40
64
  return {
41
- locale: locale as Locale,
42
- word: word as string,
65
+ locale,
66
+ words,
43
67
  };
44
68
  };
45
69
 
@@ -1,12 +1,13 @@
1
- import { getLocaleConfig } from '@scrabble-solver/configs';
1
+ import { getLocaleConfig, isConfigId } from '@scrabble-solver/configs';
2
2
  import { BLANK } from '@scrabble-solver/constants';
3
3
  import { dictionaries } from '@scrabble-solver/dictionaries';
4
4
  import logger from '@scrabble-solver/logger';
5
5
  import Solver from '@scrabble-solver/solver';
6
- import { Board, Config, Locale, Tile } from '@scrabble-solver/types';
6
+ import { Board, Config, isBoardJson, isLocale, Locale, Tile } from '@scrabble-solver/types';
7
7
  import { NextApiRequest, NextApiResponse } from 'next';
8
8
 
9
- import { getServerLoggingData, validateBoard, validateCharacters, validateConfigId, validateLocale } from 'api';
9
+ import { getServerLoggingData, isBoardValid } from 'api';
10
+ import { isStringArray } from 'lib';
10
11
 
11
12
  interface RequestData {
12
13
  board: Board;
@@ -20,6 +21,7 @@ const solve = async (request: NextApiRequest, response: NextApiResponse): Promis
20
21
 
21
22
  try {
22
23
  const { board, characters, config, locale } = parseRequest(request);
24
+
23
25
  logger.info('solve - request', {
24
26
  meta,
25
27
  payload: {
@@ -31,7 +33,7 @@ const solve = async (request: NextApiRequest, response: NextApiResponse): Promis
31
33
  locale,
32
34
  },
33
35
  });
34
- validateRequest({ board, characters, config, locale });
36
+
35
37
  const trie = await dictionaries.get(locale);
36
38
  const tiles = characters.map((character) => new Tile({ character, isBlank: character === BLANK }));
37
39
  const solver = new Solver(config, trie);
@@ -41,33 +43,51 @@ const solve = async (request: NextApiRequest, response: NextApiResponse): Promis
41
43
  const message = error instanceof Error ? error.message : 'Unknown error';
42
44
  logger.error('solve - error', { error, meta });
43
45
  response.status(500).send({ error: 'Server error', message });
46
+ throw error;
44
47
  }
45
48
  };
46
49
 
47
50
  const parseRequest = (request: NextApiRequest): RequestData => {
48
- const { board, characters, configId, locale } = request.body;
51
+ const { board: boardJson, characters, configId, locale } = request.body;
52
+
53
+ if (!isLocale(locale)) {
54
+ throw new Error('Invalid "locale" parameter');
55
+ }
56
+
57
+ if (!isConfigId(configId)) {
58
+ throw new Error('Invalid "configId" parameter');
59
+ }
49
60
 
50
- validateConfigId(configId);
51
- validateLocale(locale);
52
61
  const config = getLocaleConfig(configId, locale);
53
- validateBoard(board, config);
54
- validateCharacters(characters, config);
55
62
 
56
- return {
57
- board: Board.fromJson(board),
58
- characters,
59
- config,
60
- locale,
61
- };
62
- };
63
+ if (!isBoardJson(boardJson) || !isBoardValid(boardJson, config)) {
64
+ throw new Error('Invalid "board" parameter');
65
+ }
66
+
67
+ if (!isStringArray(characters) || characters.length === 0) {
68
+ throw new Error('Invalid "characters" parameter');
69
+ }
63
70
 
64
- const validateRequest = ({ board, characters, config }: RequestData): void => {
71
+ for (const character of characters) {
72
+ if (!config.hasCharacter(character) && character !== BLANK) {
73
+ throw new Error('Invalid "characters" parameter');
74
+ }
75
+ }
76
+
77
+ const board = Board.fromJson(boardJson);
65
78
  const blankTilesCount = characters.filter((character) => character === BLANK).length;
66
79
  const blanksCount = board.getBlanksCount() + blankTilesCount;
67
80
 
68
- if (blanksCount > config.numberOfBlanks) {
69
- throw new Error(`Too many blank tiles passed (board: ${board.getBlanksCount()}, tiles: ${blankTilesCount})`);
81
+ if (blanksCount > config.blanksCount) {
82
+ throw new Error('Too many blank tiles passed');
70
83
  }
84
+
85
+ return {
86
+ board,
87
+ characters,
88
+ config,
89
+ locale,
90
+ };
71
91
  };
72
92
 
73
93
  export default solve;
@@ -13,6 +13,7 @@ const visit = async (request: NextApiRequest, response: NextApiResponse): Promis
13
13
  const message = error instanceof Error ? error.message : 'Unknown error';
14
14
  logger.error('visit - error', { error, meta });
15
15
  response.status(500).send({ error: 'Server error', message });
16
+ throw error;
16
17
  }
17
18
  };
18
19
 
@@ -25,11 +25,15 @@
25
25
  padding: var(--spacing--l);
26
26
  }
27
27
 
28
- .logoContainer {
28
+ .navLogo {
29
29
  display: flex;
30
30
  flex: 1;
31
31
  }
32
32
 
33
+ .logoContainer {
34
+ display: flex;
35
+ }
36
+
33
37
  .logo {
34
38
  height: 60px;
35
39
  user-select: none;
@@ -127,13 +131,3 @@
127
131
  .submitInput {
128
132
  display: none;
129
133
  }
130
-
131
- .version {
132
- position: fixed;
133
- bottom: var(--spacing--m);
134
- left: 0;
135
- right: 0;
136
- font-size: var(--font--size--xs);
137
- color: var(--color--background);
138
- text-align: center;
139
- }
@@ -74,8 +74,10 @@ const Index: FunctionComponent<Props> = ({ version }) => {
74
74
  <>
75
75
  <div className={classNames(styles.index, { [styles.initialized]: isInitialized })}>
76
76
  <div className={styles.nav}>
77
- <div className={styles.logoContainer}>
78
- <Logo className={styles.logo} />
77
+ <div className={styles.navLogo}>
78
+ <a className={styles.logoContainer} href="/" title={version}>
79
+ <Logo className={styles.logo} />
80
+ </a>
79
81
  </div>
80
82
 
81
83
  <NavButtons
@@ -114,8 +116,6 @@ const Index: FunctionComponent<Props> = ({ version }) => {
114
116
  <Rack className={styles.rack} />
115
117
  <input className={styles.submitInput} tabIndex={-1} type="submit" />
116
118
  </form>
117
-
118
- <span className={styles.version}>v{version}</span>
119
119
  </div>
120
120
 
121
121
  <Settings isOpen={showSettings} onClose={() => setShowSettings(false)} />
@@ -138,7 +138,7 @@ const readVersion = async (): Promise<string> => {
138
138
  const packageJsonFilepath = path.resolve(process.cwd(), 'package.json');
139
139
  const data = await fs.promises.readFile(packageJsonFilepath, 'utf-8');
140
140
  const packageJson = JSON.parse(data);
141
- return packageJson.version;
141
+ return `v${packageJson.version}`;
142
142
  };
143
143
 
144
144
  export default Index;
@@ -1,9 +1,9 @@
1
1
  import { Locale, WordDefinition } from '@scrabble-solver/types';
2
2
 
3
- const findWordDefinition = async (locale: Locale, word: string): Promise<WordDefinition> => {
3
+ const findWordDefinitions = async (locale: Locale, word: string): Promise<WordDefinition[]> => {
4
4
  const url = `/api/dictionary/${locale}/${word}`;
5
5
  const json = await fetch(url).then((response) => response.json());
6
- return WordDefinition.fromJson(json);
6
+ return json.map(WordDefinition.fromJson);
7
7
  };
8
8
 
9
- export default findWordDefinition;
9
+ export default findWordDefinitions;
package/src/sdk/index.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { default as findWordDefinition } from './findWordDefinition';
1
+ export { default as findWordDefinitions } from './findWordDefinitions';
2
2
  export { default as solve } from './solve';
3
3
  export { default as visit } from './visit';
@@ -1,9 +1,18 @@
1
1
  import { combineReducers } from 'redux';
2
2
 
3
- import { boardSlice, dictionarySlice, rackSlice, resultsSlice, settingsSlice, solveSlice } from './slices';
3
+ import {
4
+ boardSlice,
5
+ dictionarySlice,
6
+ cellFilterSlice,
7
+ rackSlice,
8
+ resultsSlice,
9
+ settingsSlice,
10
+ solveSlice,
11
+ } from './slices';
4
12
 
5
13
  const rootReducer = combineReducers({
6
14
  board: boardSlice.reducer,
15
+ cellFilter: cellFilterSlice.reducer,
7
16
  dictionary: dictionarySlice.reducer,
8
17
  rack: rackSlice.reducer,
9
18
  results: resultsSlice.reducer,
@@ -3,22 +3,31 @@ import { Result } from '@scrabble-solver/types';
3
3
  import { call, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects';
4
4
 
5
5
  import { memoize } from 'lib';
6
- import { findWordDefinition, solve, visit } from 'sdk';
6
+ import { findWordDefinitions, solve, visit } from 'sdk';
7
7
 
8
8
  import { initialize, reset } from './actions';
9
9
  import {
10
10
  selectAutoGroupTiles,
11
11
  selectBoard,
12
+ selectCellIsFiltered,
12
13
  selectCharacters,
13
14
  selectConfig,
14
15
  selectDictionary,
15
16
  selectLocale,
16
17
  } from './selectors';
17
- import { boardSlice, dictionarySlice, rackSlice, resultsSlice, settingsSlice, solveSlice } from './slices';
18
+ import {
19
+ boardSlice,
20
+ cellFilterSlice,
21
+ dictionarySlice,
22
+ rackSlice,
23
+ resultsSlice,
24
+ settingsSlice,
25
+ solveSlice,
26
+ } from './slices';
18
27
 
19
28
  const SUBMIT_DELAY = 150;
20
29
 
21
- const memoizedFindWordDefinition = memoize(findWordDefinition);
30
+ const memoizedFindWordDefinitions = memoize(findWordDefinitions);
22
31
 
23
32
  // Can't conveniently type generators for sagas yet,
24
33
  // see: https://github.com/microsoft/TypeScript/issues/43632
@@ -26,6 +35,7 @@ const memoizedFindWordDefinition = memoize(findWordDefinition);
26
35
  type AnyGenerator = Generator<any, any, any>;
27
36
 
28
37
  export function* rootSaga(): AnyGenerator {
38
+ yield takeEvery(boardSlice.actions.changeCellValue.type, onCellValueChange);
29
39
  yield takeEvery(resultsSlice.actions.applyResult.type, onApplyResult);
30
40
  yield takeEvery(resultsSlice.actions.changeResultCandidate.type, onResultCandidateChange);
31
41
  yield takeEvery(settingsSlice.actions.changeConfigId.type, onConfigIdChange);
@@ -36,9 +46,18 @@ export function* rootSaga(): AnyGenerator {
36
46
  yield takeLatest(solveSlice.actions.submit.type, onSubmit);
37
47
  }
38
48
 
49
+ function* onCellValueChange({ payload }: PayloadAction<{ value: string; x: number; y: number }>): AnyGenerator {
50
+ const isFiltered = yield select((state) => selectCellIsFiltered(state, payload));
51
+
52
+ if (isFiltered) {
53
+ yield put(cellFilterSlice.actions.toggle(payload));
54
+ }
55
+ }
56
+
39
57
  function* onApplyResult({ payload: result }: PayloadAction<Result>): AnyGenerator {
40
58
  const autoGroupTiles = yield select(selectAutoGroupTiles);
41
59
  yield put(boardSlice.actions.applyResult(result));
60
+ yield put(cellFilterSlice.actions.reset());
42
61
  yield put(rackSlice.actions.removeTiles(result.tiles));
43
62
  yield put(rackSlice.actions.groupTiles(autoGroupTiles));
44
63
  }
@@ -53,13 +72,13 @@ function* onDictionarySubmit(): AnyGenerator {
53
72
  const { input: word } = yield select(selectDictionary);
54
73
  const locale = yield select(selectLocale);
55
74
 
56
- if (!memoizedFindWordDefinition.hasCache(locale, word)) {
75
+ if (!memoizedFindWordDefinitions.hasCache(locale, word)) {
57
76
  yield delay(SUBMIT_DELAY);
58
77
  }
59
78
 
60
79
  try {
61
- const wordDefinition = yield call(memoizedFindWordDefinition, locale, word);
62
- yield put(dictionarySlice.actions.submitSuccess(wordDefinition));
80
+ const wordDefinitions = yield call(memoizedFindWordDefinitions, locale, word);
81
+ yield put(dictionarySlice.actions.submitSuccess(wordDefinitions));
63
82
  } catch (error) {
64
83
  yield put(dictionarySlice.actions.submitFailure());
65
84
  }
@@ -72,6 +91,7 @@ function* onInitialize(): AnyGenerator {
72
91
 
73
92
  function* onReset(): AnyGenerator {
74
93
  yield put(boardSlice.actions.reset());
94
+ yield put(cellFilterSlice.actions.reset());
75
95
  yield put(dictionarySlice.actions.reset());
76
96
  yield put(rackSlice.actions.reset());
77
97
  yield put(resultsSlice.actions.reset());
@@ -85,7 +105,7 @@ function* onLocaleChange(): AnyGenerator {
85
105
 
86
106
  function* onResultCandidateChange({ payload: result }: PayloadAction<Result | null>): AnyGenerator {
87
107
  if (result) {
88
- yield put(dictionarySlice.actions.changeInput(result.word));
108
+ yield put(dictionarySlice.actions.changeInput(result.words.join(', ')));
89
109
  yield put(dictionarySlice.actions.submit());
90
110
  }
91
111
  }
@@ -121,12 +141,12 @@ function* ensureProperTilesCount(): AnyGenerator {
121
141
  const { config } = yield select(selectConfig);
122
142
  const characters = yield select(selectCharacters);
123
143
 
124
- if (config.maximumNumberOfCharacters > characters.length) {
125
- const differenceCount = Math.abs(config.maximumNumberOfCharacters - characters.length);
144
+ if (config.maximumCharactersCount > characters.length) {
145
+ const differenceCount = Math.abs(config.maximumCharactersCount - characters.length);
126
146
  yield put(rackSlice.actions.init([...characters, ...Array(differenceCount).fill(null)]));
127
- } else if (config.maximumNumberOfCharacters < characters.length) {
128
- const nonNulls = characters.filter(Boolean).slice(0, config.maximumNumberOfCharacters);
129
- const differenceCount = Math.abs(config.maximumNumberOfCharacters - nonNulls.length);
147
+ } else if (config.maximumCharactersCount < characters.length) {
148
+ const nonNulls = characters.filter(Boolean).slice(0, config.maximumCharactersCount);
149
+ const differenceCount = Math.abs(config.maximumCharactersCount - nonNulls.length);
130
150
  const autoGroupTiles = yield select(selectAutoGroupTiles);
131
151
  yield put(rackSlice.actions.init([...nonNulls, ...Array(differenceCount).fill(null)]));
132
152
  yield put(rackSlice.actions.groupTiles(autoGroupTiles));
@@ -1,6 +1,6 @@
1
1
  import { createSelector } from '@reduxjs/toolkit';
2
2
  import { getLocaleConfig } from '@scrabble-solver/configs';
3
- import { Cell, Config, Tile } from '@scrabble-solver/types';
3
+ import { Cell, Config, Result, Tile } from '@scrabble-solver/types';
4
4
 
5
5
  import i18n from 'i18n';
6
6
  import { findCell, getRemainingTiles, getRemainingTilesGroups, sortResults, unorderedArraysEqual } from 'lib';
@@ -10,6 +10,8 @@ import { RootState } from './types';
10
10
 
11
11
  const selectCell = (_: unknown, cell: Cell): Cell => cell;
12
12
 
13
+ const selectPoint = (_: unknown, point: { x: number; y: number }): { x: number; y: number } => point;
14
+
13
15
  const selectCharacter = (_: unknown, character: string | null): string | null => character;
14
16
 
15
17
  const selectTile = (_: unknown, tile: Tile | null): Tile | null => tile;
@@ -18,6 +20,8 @@ const selectBoardRoot = (state: RootState): RootState['board'] => state.board;
18
20
 
19
21
  const selectDictionaryRoot = (state: RootState): RootState['dictionary'] => state.dictionary;
20
22
 
23
+ const selectCellFilterRoot = (state: RootState): RootState['cellFilter'] => state.cellFilter;
24
+
21
25
  const selectRackRoot = (state: RootState): RootState['rack'] => state.rack;
22
26
 
23
27
  const selectResultsRoot = (state: RootState): RootState['results'] => state.results;
@@ -38,6 +42,12 @@ export const selectConfigId = createSelector([selectSettingsRoot], (settings) =>
38
42
 
39
43
  export const selectConfig = createSelector([selectConfigId, selectLocale], getLocaleConfig);
40
44
 
45
+ export const selectCellFilter = selectCellFilterRoot;
46
+
47
+ export const selectCellIsFiltered = createSelector([selectCellFilter, selectPoint], (cellFilter, { x, y }) => {
48
+ return cellFilter.some((cell) => cell.x === x && cell.y === y);
49
+ });
50
+
41
51
  export const selectResults = createSelector([selectResultsRoot], (results) => results.results);
42
52
 
43
53
  export const selectResultsQuery = createSelector([selectResultsRoot], (results) => results.query);
@@ -51,16 +61,40 @@ export const selectSortedResults = createSelector(
51
61
  sortResults,
52
62
  );
53
63
 
64
+ const filterResultsByQuery = (results: Result[], query: string): Result[] => {
65
+ if (query.trim().length === 0) {
66
+ return results;
67
+ }
68
+
69
+ let regExp: RegExp | undefined;
70
+
71
+ try {
72
+ regExp = new RegExp(query, 'gi');
73
+ } catch {
74
+ return results;
75
+ }
76
+
77
+ return results.filter((result) => {
78
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
79
+ return regExp!.test(result.word);
80
+ });
81
+ };
82
+
54
83
  export const selectSortedFilteredResults = createSelector(
55
- [selectSortedResults, selectResultsQuery],
56
- (results, query) => {
57
- if (!results || query.trim().length === 0) {
84
+ [selectSortedResults, selectResultsQuery, selectCellFilter],
85
+ (results, query, cellFilter) => {
86
+ if (!results) {
58
87
  return results;
59
88
  }
60
89
 
61
- return results.filter((result) => {
62
- const regExp = new RegExp(query, 'gi');
63
- return regExp.test(result.word);
90
+ const filteredByQuery = filterResultsByQuery(results, query);
91
+
92
+ if (!cellFilter) {
93
+ return filteredByQuery;
94
+ }
95
+
96
+ return filteredByQuery.filter((result) => {
97
+ return cellFilter.every(({ x, y }) => result.cells.some((cell) => cell.x === x && cell.y === y));
64
98
  });
65
99
  },
66
100
  );
@@ -0,0 +1,7 @@
1
+ import { Cell } from '@scrabble-solver/types';
2
+
3
+ export type Point = Pick<Cell, 'x' | 'y'>;
4
+
5
+ const cellFilterInitialState: Point[] = [];
6
+
7
+ export default cellFilterInitialState;
@@ -0,0 +1,24 @@
1
+ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2
+
3
+ import cellFilterInitialState, { Point } from './cellFilterInitialState';
4
+
5
+ const cellFilterSlice = createSlice({
6
+ initialState: cellFilterInitialState,
7
+ name: 'cellFilter',
8
+ reducers: {
9
+ toggle: (state, action: PayloadAction<Point>) => {
10
+ const { x, y } = action.payload;
11
+ const has = state.some((point) => point.x === x && point.y === y);
12
+
13
+ if (has) {
14
+ return state.filter((point) => point.x !== x || point.y !== y);
15
+ }
16
+
17
+ return [...state, action.payload];
18
+ },
19
+
20
+ reset: () => cellFilterInitialState,
21
+ },
22
+ });
23
+
24
+ export default cellFilterSlice;
@@ -1,9 +1,9 @@
1
+ import { WordDefinition } from '@scrabble-solver/types';
2
+
1
3
  const dictionaryInitialState = {
2
- definitions: [] as string[],
3
4
  input: '',
4
- isAllowed: null as boolean | null,
5
5
  isLoading: false,
6
- word: undefined as string | undefined,
6
+ results: [] as WordDefinition[],
7
7
  };
8
8
 
9
9
  export default dictionaryInitialState;
@@ -16,30 +16,24 @@ const dictionarySlice = createSlice({
16
16
  submit: (state) => {
17
17
  return {
18
18
  ...state,
19
- definitions: dictionaryInitialState.definitions,
20
- isAllowed: dictionaryInitialState.isAllowed,
21
19
  isLoading: true,
22
- word: dictionaryInitialState.word,
20
+ results: dictionaryInitialState.results,
23
21
  };
24
22
  },
25
23
 
26
24
  submitFailure: (state) => {
27
25
  return {
28
26
  ...state,
29
- definitions: dictionaryInitialState.definitions,
30
- isAllowed: false,
31
27
  isLoading: false,
32
- word: state.input,
28
+ results: dictionaryInitialState.results,
33
29
  };
34
30
  },
35
31
 
36
- submitSuccess: (state, action: PayloadAction<WordDefinition>) => {
32
+ submitSuccess: (state, action: PayloadAction<WordDefinition[]>) => {
37
33
  return {
38
34
  ...state,
39
- definitions: action.payload.definitions,
40
- isAllowed: action.payload.isAllowed,
41
35
  isLoading: false,
42
- word: action.payload.word,
36
+ results: action.payload,
43
37
  };
44
38
  },
45
39
  },
@@ -1,5 +1,7 @@
1
1
  export { default as boardInitialState } from './boardInitialState';
2
2
  export { default as boardSlice } from './boardSlice';
3
+ export { default as cellFilterInitialState } from './cellFilterInitialState';
4
+ export { default as cellFilterSlice } from './cellFilterSlice';
3
5
  export { default as dictionaryInitialState } from './dictionaryInitialState';
4
6
  export { default as dictionarySlice } from './dictionarySlice';
5
7
  export { default as rackInitialState } from './rackInitialState';
@@ -30,6 +30,7 @@ export enum ResultColumn {
30
30
  }
31
31
 
32
32
  export type TranslationKey =
33
+ | 'cell.filter-cell'
33
34
  | 'cell.set-blank'
34
35
  | 'cell.set-not-blank'
35
36
  | 'cell.toggle-direction'
@@ -1 +0,0 @@
1
- self.__BUILD_MANIFEST=function(s,a,e){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,a,e,"static/css/3159cfe62ff742a3.css","static/chunks/pages/index-13ea7770a65c69ee.js"],"/404":[s,a,e,"static/chunks/pages/404-30c06e61d256c5b2.js"],"/_error":["static/chunks/pages/_error-a4ba2246ff8fb532.js"],sortedPages:["/","/404","/_app","/_error"]}}("static/chunks/758-f333b1dcdb941547.js","static/css/729bb37fe8f9bee6.css","static/chunks/56-e2797384ae4b0fc0.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();