Repository: dhsc-govuk/FingertipsNext
Pull request #589: Feature/dhscft 779 indicator search form limit
Author: login: Joe-Hendley
Created: 2025-05-21T13:58:54Z | Merged: 2025-05-23T08:47:08Z

(https://bjss-enterprise.atlassian.net/browse/DHSCFT-779)

adds a character limit to our indicator search

unit and manually tested

![image](https://github.com/user-attachments/assets/ea4575e6-5332-40ee-8cf2-5512526fc410)
![image](https://github.com/user-attachments/assets/5ef8dce8-17fc-4bdd-8b24-9e7326450d36)




Diff:
diff --git a/frontend/fingertips-frontend/app/results/page.test.tsx b/frontend/fingertips-frontend/app/results/page.test.tsx
index dc63520e2..1340db2fc 100644
--- a/frontend/fingertips-frontend/app/results/page.test.tsx
+++ b/frontend/fingertips-frontend/app/results/page.test.tsx
@@ -30,6 +30,7 @@ import { getAreaFilterData } from '@/lib/areaFilterHelpers/getAreaFilterData';
 import { generateIndicatorDocument } from '@/lib/search/mockDataHelper';
 import { getSelectedAreasDataByAreaType } from '@/lib/areaFilterHelpers/getSelectedAreasData';
 import { ALL_AREAS_SELECTED } from '@/lib/areaFilterHelpers/constants';
+import { INDICATOR_SEARCH_MAX_CHARACTERS } from '@/lib/search/indicatorSearchService';
 
 jest.mock('@/lib/areaFilterHelpers/getAreaFilterData');
 jest.mock('@/lib/areaFilterHelpers/getSelectedAreasData');
@@ -133,6 +134,30 @@ describe('Results Page', () => {
       expect(page.props.searchResults).toEqual(mockIndicatorSearchResults);
     });
 
+    it('should restrict search length', async () => {
+      mockIndicatorSearchService.searchWith.mockResolvedValue(
+        mockIndicatorSearchResults
+      );
+
+      const searchState: SearchStateParams = {
+        ...searchParams,
+        [SearchParams.SearchedIndicator]: 'A'.repeat(
+          INDICATOR_SEARCH_MAX_CHARACTERS + 1
+        ),
+        [SearchParams.IndicatorsSelected]: ['1', '2'],
+      };
+
+      const _ = await ResultsPage({
+        searchParams: generateSearchParams(searchState),
+      });
+
+      expect(mockIndicatorSearchService.searchWith).toHaveBeenCalledWith(
+        'A'.repeat(INDICATOR_SEARCH_MAX_CHARACTERS),
+        false,
+        undefined
+      );
+    });
+
     it('should pass the searchResults prop with the results of the searchWith filtered by areas selected', async () => {
       mockIndicatorSearchService.searchWith.mockResolvedValue(
         mockIndicatorSearchResults
diff --git a/frontend/fingertips-frontend/app/results/page.tsx b/frontend/fingertips-frontend/app/results/page.tsx
index 2483120b6..83d947775 100644
--- a/frontend/fingertips-frontend/app/results/page.tsx
+++ b/frontend/fingertips-frontend/app/results/page.tsx
@@ -13,6 +13,7 @@ import { getSelectedAreasDataByAreaType } from '@/lib/areaFilterHelpers/getSelec
 import { AreaTypeKeys } from '@/lib/areaFilterHelpers/areaType';
 import { areaCodeForEngland } from '@/lib/chartHelpers/constants';
 import { ALL_AREAS_SELECTED } from '@/lib/areaFilterHelpers/constants';
+import { INDICATOR_SEARCH_MAX_CHARACTERS } from '@/lib/search/indicatorSearchService';
 
 export default async function Page(
   props: Readonly<{
@@ -55,6 +56,16 @@ export default async function Page(
       stateManager.setState(updatedSearchState);
     }
 
+    if (
+      searchedIndicator &&
+      searchedIndicator.length > INDICATOR_SEARCH_MAX_CHARACTERS
+    ) {
+      stateManager.addParamValueToState(
+        SearchParams.SearchedIndicator,
+        searchedIndicator.slice(0, INDICATOR_SEARCH_MAX_CHARACTERS)
+      );
+    }
+
     const searchFilterAreas =
       groupAreaSelected === ALL_AREAS_SELECTED
         ? availableAreas?.map((area) => area.code)
@@ -62,7 +73,7 @@ export default async function Page(
 
     const searchResults =
       await SearchServiceFactory.getIndicatorSearchService().searchWith(
-        searchedIndicator ?? '',
+        searchedIndicator?.slice(0, INDICATOR_SEARCH_MAX_CHARACTERS) ?? '',
         isEnglandSelectedAsGroup,
         searchFilterAreas
       );
diff --git a/frontend/fingertips-frontend/components/atoms/CharacterCount/CharacterCount.test.tsx b/frontend/fingertips-frontend/components/atoms/CharacterCount/CharacterCount.test.tsx
new file mode 100644
index 000000000..421ff8f17
--- /dev/null
+++ b/frontend/fingertips-frontend/components/atoms/CharacterCount/CharacterCount.test.tsx
@@ -0,0 +1,85 @@
+import { render } from '@testing-library/react';
+import { CharacterCount } from '.';
+
+const underLimitText = (characterLimit: number, textLength: number) =>
+  `You have ${characterLimit - textLength} characters remaining.`;
+
+describe('CharacterCount', () => {
+  it('should not render any text if no character limit given', () => {
+    const fragment = render(<CharacterCount textLength={0} />);
+
+    expect(fragment.baseElement).toHaveTextContent('');
+  });
+
+  it('should not render any text if character limit is below 1', () => {
+    const fragment = render(
+      <CharacterCount textLength={0} characterLimit={0} />
+    );
+
+    expect(fragment.baseElement).toHaveTextContent('');
+  });
+
+  it('should render expected text if character limit above 1', () => {
+    const characterLimit = 10;
+    const textLength = 6;
+    const fragment = render(
+      <CharacterCount textLength={textLength} characterLimit={characterLimit} />
+    );
+
+    expect(fragment.baseElement).toHaveTextContent(
+      underLimitText(characterLimit, textLength)
+    );
+  });
+
+  it('should not render any text if threshold % is given and above 100', () => {
+    const fragment = render(
+      <CharacterCount
+        textLength={6}
+        characterLimit={10}
+        thresholdPercentage={101}
+      />
+    );
+
+    expect(fragment.baseElement).toHaveTextContent('');
+  });
+
+  it('should render expected text if text length is above threshold %', () => {
+    const characterLimit = 10;
+    const textLength = 6;
+    const fragment = render(
+      <CharacterCount
+        textLength={textLength}
+        characterLimit={characterLimit}
+        thresholdPercentage={50}
+      />
+    );
+
+    expect(fragment.baseElement).toHaveTextContent(
+      underLimitText(characterLimit, textLength)
+    );
+  });
+
+  it('should not render any text if text length is below threshold %', () => {
+    const fragment = render(
+      <CharacterCount
+        textLength={6}
+        characterLimit={10}
+        thresholdPercentage={75}
+      />
+    );
+
+    expect(fragment.baseElement).toHaveTextContent('');
+  });
+
+  it('should render different text if text length above character limit', () => {
+    const characterLimit = 10;
+    const textLength = 11;
+    const fragment = render(
+      <CharacterCount textLength={textLength} characterLimit={characterLimit} />
+    );
+
+    expect(fragment.baseElement).toHaveTextContent(
+      `You have ${textLength - characterLimit} characters too many`
+    );
+  });
+});
diff --git a/frontend/fingertips-frontend/components/atoms/CharacterCount/index.tsx b/frontend/fingertips-frontend/components/atoms/CharacterCount/index.tsx
new file mode 100644
index 000000000..2a4a53bb0
--- /dev/null
+++ b/frontend/fingertips-frontend/components/atoms/CharacterCount/index.tsx
@@ -0,0 +1,36 @@
+import { HintText } from 'govuk-react';
+
+export interface CharacterCountProps {
+  textLength: number;
+  characterLimit?: number;
+  thresholdPercentage?: number;
+}
+
+export function CharacterCount({
+  textLength,
+  characterLimit,
+  thresholdPercentage,
+}: Readonly<CharacterCountProps>) {
+  if (!characterLimit || characterLimit < 1) {
+    return null;
+  }
+
+  if (thresholdPercentage && thresholdPercentage > 100) {
+    return null;
+  }
+
+  if (
+    thresholdPercentage &&
+    (textLength / characterLimit) * 100 <= thresholdPercentage
+  ) {
+    return null;
+  }
+
+  return (
+    <HintText>
+      {characterLimit >= textLength
+        ? `You have ${characterLimit - textLength} characters remaining.`
+        : `You have ${textLength - characterLimit} characters too many`}
+    </HintText>
+  );
+}
diff --git a/frontend/fingertips-frontend/components/atoms/InputField/InputField.test.tsx b/frontend/fingertips-frontend/components/atoms/InputField/InputField.test.tsx
new file mode 100644
index 000000000..3e9a5fdb4
--- /dev/null
+++ b/frontend/fingertips-frontend/components/atoms/InputField/InputField.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react';
+import { InputField } from '.';
+import userEvent from '@testing-library/user-event';
+
+describe('InputField', () => {
+  it('should preserve any existing onChange handler', async () => {
+    const user = userEvent.setup();
+    const initialHandler = jest.fn();
+    render(
+      <InputField input={{ onChange: initialHandler }}>Input Field</InputField>
+    );
+
+    await user.type(screen.getByRole('textbox'), 'A');
+
+    expect(initialHandler).toHaveBeenCalled();
+  });
+
+  it('should display character count if limit given', () => {
+    render(<InputField characterLimit={10}>Input Field</InputField>);
+    expect(
+      screen.getByText('You have 10 characters remaining.')
+    ).toBeInTheDocument();
+  });
+
+  it('should update character count on change', async () => {
+    const user = userEvent.setup();
+    const initialHandler = jest.fn();
+    render(
+      <InputField characterLimit={10} input={{ onChange: initialHandler }}>
+        Input Field
+      </InputField>
+    );
+
+    await user.type(screen.getByRole('textbox'), 'A');
+
+    expect(initialHandler).toHaveBeenCalled();
+    expect(
+      screen.getByText('You have 9 characters remaining.')
+    ).toBeInTheDocument();
+  });
+});
diff --git a/frontend/fingertips-frontend/components/atoms/InputField/index.tsx b/frontend/fingertips-frontend/components/atoms/InputField/index.tsx
new file mode 100644
index 000000000..b20d37cce
--- /dev/null
+++ b/frontend/fingertips-frontend/components/atoms/InputField/index.tsx
@@ -0,0 +1,59 @@
+import { InputField as GovukInputField } from 'govuk-react';
+import { InputFieldProps as GovukInputFieldProps } from '@govuk-react/input-field';
+import { ChangeEventHandler, useState } from 'react';
+import { spacing } from '@govuk-react/lib';
+import styled from 'styled-components';
+import { CharacterCount } from '../CharacterCount';
+
+interface InputFieldProps extends GovukInputFieldProps {
+  characterLimit?: number;
+  thresholdPercentage?: number;
+}
+
+const StyledInputField = styled(GovukInputField)(
+  spacing.withWhiteSpace({ marginBottom: 1 })
+);
+
+const StyledPositionDiv = styled.div({
+  position: 'absolute',
+});
+
+const StyledPaddingDiv = styled.div({ paddingBottom: '30px' });
+
+export function InputField(props: Readonly<InputFieldProps>) {
+  const { characterLimit, thresholdPercentage, input } = props;
+
+  const [inputTextLength, setInputTextLength] = useState(
+    input?.defaultValue?.toString().length ?? 0
+  );
+  const existingOnChangeHandler = input?.onChange;
+  const onChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
+    if (existingOnChangeHandler !== undefined) {
+      existingOnChangeHandler(e);
+    }
+
+    setInputTextLength(e.target.value.length);
+  };
+
+  return (
+    <StyledPaddingDiv>
+      <StyledInputField
+        {...props}
+        input={{
+          ...input,
+          maxLength: characterLimit ?? input?.maxLength,
+          onChange: characterLimit ? onChangeHandler : existingOnChangeHandler,
+        }}
+      >
+        {props.children}
+      </StyledInputField>
+      <StyledPositionDiv>
+        <CharacterCount
+          textLength={inputTextLength}
+          characterLimit={characterLimit}
+          thresholdPercentage={thresholdPercentage}
+        />
+      </StyledPositionDiv>
+    </StyledPaddingDiv>
+  );
+}
diff --git a/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/IndicatorSearchForm.test.tsx b/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/IndicatorSearchForm.test.tsx
index b2de6e6dc..51a4513e8 100644
--- a/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/IndicatorSearchForm.test.tsx
+++ b/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/IndicatorSearchForm.test.tsx
@@ -6,6 +6,7 @@ import { SearchParams, SearchStateParams } from '@/lib/searchStateManager';
 import { LoaderContext } from '@/context/LoaderContext';
 import { SearchStateContext } from '@/context/SearchStateContext';
 import userEvent from '@testing-library/user-event';
+import { INDICATOR_SEARCH_MAX_CHARACTERS } from '@/lib/search/indicatorSearchService';
 
 jest.mock('react', () => {
   const originalModule = jest.requireActual('react');
@@ -114,3 +115,43 @@ it('should display an error message when there is an error', () => {
     screen.queryByTestId('indicator-search-form-error')
   ).toBeInTheDocument();
 });
+
+it('should not display character if indicator is under the 75% threshold', () => {
+  const searchFormState: IndicatorSearchFormState = {
+    ...initialState,
+    indicator: 'A'.repeat(INDICATOR_SEARCH_MAX_CHARACTERS * 0.75),
+  };
+  render(<IndicatorSearchForm indicatorSearchFormState={searchFormState} />);
+
+  expect(
+    screen.queryByText('You have 50 characters remaining.', { exact: false })
+  ).not.toBeInTheDocument();
+});
+
+it('should display character count if indicator is over the 75% threshold', () => {
+  const searchFormState: IndicatorSearchFormState = {
+    ...initialState,
+    indicator: 'A'.repeat(INDICATOR_SEARCH_MAX_CHARACTERS * 0.75 + 1),
+  };
+  render(<IndicatorSearchForm indicatorSearchFormState={searchFormState} />);
+
+  expect(
+    screen.getByText('You have 49 characters remaining.')
+  ).toBeInTheDocument();
+});
+
+it('should update character count on change', async () => {
+  const searchFormState: IndicatorSearchFormState = {
+    ...initialState,
+    indicator: 'A'.repeat(INDICATOR_SEARCH_MAX_CHARACTERS * 0.75),
+  };
+  render(<IndicatorSearchForm indicatorSearchFormState={searchFormState} />);
+
+  const user = userEvent.setup();
+
+  await user.type(screen.getByRole('searchbox'), 'A');
+
+  expect(
+    screen.getByText('You have 49 characters remaining.')
+  ).toBeInTheDocument();
+});
diff --git a/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/__snapshots__/IndicatorSearchForm.test.tsx.snap b/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/__snapshots__/IndicatorSearchForm.test.tsx.snap
index fde94e5ec..f4ba37e94 100644
--- a/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/__snapshots__/IndicatorSearchForm.test.tsx.snap
+++ b/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/__snapshots__/IndicatorSearchForm.test.tsx.snap
@@ -2,7 +2,7 @@
 
 exports[`snapshot test - renders the form 1`] = `
 <DocumentFragment>
-  .c2 {
+  .c3 {
   margin: 0;
   font-family: "nta",Arial,sans-serif;
   -webkit-font-smoothing: antialiased;
@@ -13,11 +13,11 @@ exports[`snapshot test - renders the form 1`] = `
   margin-bottom: 15px;
 }
 
-.c2 > p {
+.c3 > p {
   margin: 0;
 }
 
-.c2 > p > code {
+.c3 > p > code {
   padding: 0.2em 0.4em;
   margin: 0;
   font-size: 85%;
@@ -25,7 +25,7 @@ exports[`snapshot test - renders the form 1`] = `
   border-radius: 3px;
 }
 
-.c2 > pre {
+.c3 > pre {
   padding: 16px;
   overflow: auto;
   font-size: 85%;
@@ -34,7 +34,7 @@ exports[`snapshot test - renders the form 1`] = `
   border-radius: 3px;
 }
 
-.c2 > pre > code {
+.c3 > pre > code {
   display: inline;
   padding: 0;
   margin: 0;
@@ -111,7 +111,7 @@ exports[`snapshot test - renders the form 1`] = `
   outline-offset: 0;
 }
 
-.c7 {
+.c8 {
   box-sizing: border-box;
   display: -webkit-box;
   display: -webkit-flex;
@@ -122,42 +122,42 @@ exports[`snapshot test - renders the form 1`] = `
   margin-bottom: 0;
 }
 
-.c8 {
+.c2 {
   margin-bottom: 20px;
 }
 
-.c3 {
+.c4 {
   padding-bottom: 2px;
 }
 
-.c4 {
+.c5 {
   margin-bottom: 0;
 }
 
-.c5 {
+.c6 {
   color: #505A5F;
 }
 
-.c6 {
+.c7 {
   margin-bottom: 15px;
 }
 
 @media print {
-  .c2 {
+  .c3 {
     font-size: 14px;
     line-height: 1.15;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c2 {
+  .c3 {
     font-size: 19px;
     line-height: 1.3157894736842106;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c2 {
+  .c3 {
     margin-bottom: 20px;
   }
 }
@@ -169,31 +169,31 @@ exports[`snapshot test - renders the form 1`] = `
 }
 
 @media only screen and (min-width:641px) {
-  .c7 {
+  .c8 {
     margin-bottom: 0;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c8 {
+  .c2 {
     margin-bottom: 30px;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c4 {
+  .c5 {
     margin-bottom: 0;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c6 {
+  .c7 {
     margin-bottom: 15px;
   }
 }
 
 <div
-    class="c0 c1"
+    class="c0 c1 c2"
     data-testid="indicator-search-form"
   >
     <input
@@ -202,26 +202,27 @@ exports[`snapshot test - renders the form 1`] = `
       value="{"si":"test value"}"
     />
     <div
-      class="c2 c3 c4"
+      class="c3 c4 c5"
     >
       <p>
         Search by Subject
       </p>
     </div>
     <div
-      class="c2 c5 c6"
+      class="c3 c6 c7"
     >
       <p>
         For example, smoking, diabetes prevalence, or a specific indicator ID
       </p>
     </div>
     <div
-      class="c7 c8"
+      class="c8"
     >
       <input
         class="c9"
         data-testid="indicator-search-form-input"
         id="indicator"
+        maxlength="200"
         name="indicator"
         style="border-color: #d4351c; border-width: 2px; border-style: solid;"
         title="indicator"
diff --git a/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/index.tsx b/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/index.tsx
index b58cfa9c4..99f196799 100644
--- a/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/index.tsx
+++ b/frontend/fingertips-frontend/components/forms/IndicatorSearchForm/index.tsx
@@ -5,11 +5,13 @@ import { IndicatorSearchFormState } from './indicatorSearchActions';
 import { GovukColours } from '@/lib/styleHelpers/colours';
 import { useLoadingState } from '@/context/LoaderContext';
 import { useSearchState } from '@/context/SearchStateContext';
-import { useEffect } from 'react';
+import { ChangeEvent, useEffect, useState } from 'react';
+import { INDICATOR_SEARCH_MAX_CHARACTERS } from '@/lib/search/indicatorSearchService';
+import { CharacterCount } from '@/components/atoms/CharacterCount';
 
 const govukErrorBorderWidth = '2px';
 
-const StyledSearchBox = styled(SearchBox)(
+const StyledFormGroup = styled(FormGroup)(
   spacing.withWhiteSpace({ marginBottom: 6 })
 );
 
@@ -29,6 +31,10 @@ export const IndicatorSearchForm = ({
   const { setIsLoading } = useLoadingState();
   const { getSearchState } = useSearchState();
   const searchState = getSearchState();
+  const indicatorSearchTerm = indicatorSearchFormState.indicator;
+  const [inputTextLength, setInputTextLength] = useState(
+    indicatorSearchTerm.length
+  );
 
   useEffect(() => {
     if (indicatorSearchFormState.message) {
@@ -37,7 +43,7 @@ export const IndicatorSearchForm = ({
   });
 
   return (
-    <FormGroup
+    <StyledFormGroup
       error={indicatorSearchFormState.message !== undefined}
       data-testid="indicator-search-form"
     >
@@ -58,14 +64,18 @@ export const IndicatorSearchForm = ({
       ) : (
         ''
       )}
-      <StyledSearchBox>
+      <SearchBox>
         {SearchBox.Input && (
           <SearchBox.Input
+            onChange={(e: ChangeEvent<HTMLInputElement>) => {
+              setInputTextLength(e.target.value.length);
+            }}
             title="indicator"
             id="indicator"
             name="indicator"
             data-testid="indicator-search-form-input"
-            defaultValue={indicatorSearchFormState.indicator}
+            defaultValue={indicatorSearchTerm}
+            maxLength={INDICATOR_SEARCH_MAX_CHARACTERS}
             style={
               indicatorSearchFormState.errors
                 ? {
@@ -84,7 +94,12 @@ export const IndicatorSearchForm = ({
             data-testid="indicator-search-form-submit"
           />
         )}
-      </StyledSearchBox>
-    </FormGroup>
+      </SearchBox>
+      <CharacterCount
+        textLength={inputTextLength}
+        characterLimit={INDICATOR_SEARCH_MAX_CHARACTERS}
+        thresholdPercentage={75}
+      />
+    </StyledFormGroup>
   );
 };
diff --git a/frontend/fingertips-frontend/components/forms/SearchForm/__snapshots__/SearchForm.test.tsx.snap b/frontend/fingertips-frontend/components/forms/SearchForm/__snapshots__/SearchForm.test.tsx.snap
index cbaa4908a..81718b20d 100644
--- a/frontend/fingertips-frontend/components/forms/SearchForm/__snapshots__/SearchForm.test.tsx.snap
+++ b/frontend/fingertips-frontend/components/forms/SearchForm/__snapshots__/SearchForm.test.tsx.snap
@@ -2,7 +2,7 @@
 
 exports[`SearchForm snapshot test - renders the form 1`] = `
 <DocumentFragment>
-  .c8 {
+  .c10 {
   font-family: "nta",Arial,sans-serif;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
@@ -27,36 +27,36 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   margin-bottom: 22px;
 }
 
-.c8:focus {
+.c10:focus {
   outline: 3px solid #ffdd00;
   outline-offset: 0;
 }
 
-.c8:link,
-.c8:visited,
-.c8:active,
-.c8:hover {
+.c10:link,
+.c10:visited,
+.c10:active,
+.c10:hover {
   color: #ffffff;
   -webkit-text-decoration: none;
   text-decoration: none;
 }
 
-.c8::-moz-focus-inner {
+.c10::-moz-focus-inner {
   padding: 0;
   border: 0;
 }
 
-.c8:hover,
-.c8:focus {
+.c10:hover,
+.c10:focus {
   background-color: #00572e;
 }
 
-.c8:active {
+.c10:active {
   top: 2px;
   box-shadow: none;
 }
 
-.c8::before {
+.c10::before {
   content: "";
   display: block;
   position: absolute;
@@ -67,34 +67,34 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   background: transparent;
 }
 
-.c8:active::before {
+.c10:active::before {
   top: -4px;
 }
 
-.c8:disabled {
+.c10:disabled {
   opacity: 0.5;
   background: #00703c;
 }
 
-.c8:disabled:hover {
+.c10:disabled:hover {
   background-color: #00703c;
   cursor: default;
 }
 
-.c8:disabled:focus {
+.c10:disabled:focus {
   outline: none;
 }
 
-.c8:disabled:active {
+.c10:disabled:active {
   top: 0;
   box-shadow: 0 2px 0 #002413;
 }
 
-.c8 svg {
+.c10 svg {
   max-width: 15px;
 }
 
-.c4 {
+.c5 {
   font-family: "nta",Arial,sans-serif;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
@@ -106,7 +106,7 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   color: #6f777b;
 }
 
-.c3 {
+.c4 {
   font-family: "nta",Arial,sans-serif;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
@@ -120,7 +120,7 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   margin-bottom: 0;
 }
 
-.c5 {
+.c6 {
   font-family: "nta",Arial,sans-serif;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
@@ -140,22 +140,22 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   margin-bottom: 0;
 }
 
-.c5:focus {
+.c6:focus {
   outline: 3px solid #ffdd00;
   outline-offset: 0;
 }
 
-.c5::-webkit-outer-spin-button,
-.c5::-webkit-inner-spin-button {
+.c6::-webkit-outer-spin-button,
+.c6::-webkit-inner-spin-button {
   margin: 0;
   -webkit-appearance: none;
 }
 
-.c5[type="number"] {
+.c6[type="number"] {
   -moz-appearance: textfield;
 }
 
-.c1 {
+.c2 {
   display: -webkit-box;
   display: -webkit-flex;
   display: -ms-flexbox;
@@ -167,7 +167,7 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   margin-bottom: 0;
 }
 
-.c1:after {
+.c2:after {
   content: '';
   display: table;
   clear: both;
@@ -186,15 +186,15 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   margin-bottom: 15px;
 }
 
-.c7 {
+.c9 {
   margin-bottom: 5px;
 }
 
-.c6 {
+.c8 {
   margin-bottom: 1em;
 }
 
-.c10 {
+.c12 {
   display: -webkit-box;
   display: -webkit-flex;
   display: -ms-flexbox;
@@ -213,12 +213,12 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   cursor: pointer;
 }
 
-.c10 circle {
+.c12 circle {
   outline: none;
   fill: none;
 }
 
-.c9 {
+.c11 {
   display: -webkit-box;
   display: -webkit-flex;
   display: -ms-flexbox;
@@ -246,16 +246,16 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   align-items: center;
 }
 
-.c9:hover svg circle {
+.c11:hover svg circle {
   fill: #0B0C0C;
   stroke: #0B0C0C;
 }
 
-.c9:hover svg path {
+.c11:hover svg path {
   stroke: #FFFFFF;
 }
 
-.c11 {
+.c13 {
   font-weight: 300;
   color: #1D70B8;
   padding: 2px 1px 1px 1px;
@@ -264,104 +264,112 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
   font-size: 19px;
 }
 
-.c2 {
-  margin-bottom: 20px;
+.c3 {
+  margin-bottom: 5px;
+}
+
+.c7 {
+  position: absolute;
+}
+
+.c1 {
+  padding-bottom: 30px;
 }
 
 @media print {
-  .c8 {
+  .c10 {
     font-size: 14px;
     line-height: 19px;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c8 {
+  .c10 {
     font-size: 19px;
     line-height: 19px;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c8 {
+  .c10 {
     width: auto;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c8 svg {
+  .c10 svg {
     margin-left: 10px;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c8 {
+  .c10 {
     margin-bottom: 32px;
   }
 }
 
 @media print {
-  .c4 {
+  .c5 {
     font-size: 14px;
     line-height: 1.15;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c4 {
+  .c5 {
     font-size: 19px;
     line-height: 1.3157894736842106;
   }
 }
 
 @media print {
-  .c3 {
+  .c4 {
     font-size: 14px;
     line-height: 1.15;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c3 {
+  .c4 {
     font-size: 19px;
     line-height: 1.3157894736842106;
   }
 }
 
 @media print {
-  .c3 {
+  .c4 {
     color: #000;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c3 {
+  .c4 {
     margin-bottom: 0;
   }
 }
 
 @media print {
-  .c5 {
+  .c6 {
     font-size: 14px;
     line-height: 1.15;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c5 {
+  .c6 {
     font-size: 19px;
     line-height: 1.3157894736842106;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c5 {
+  .c6 {
     margin-bottom: 0;
   }
 }
 
 @media only screen and (min-width:641px) {
-  .c1 {
+  .c2 {
     margin-bottom: 0;
   }
 }
@@ -393,8 +401,8 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
 }
 
 @media only screen and (min-width:641px) {
-  .c2 {
-    margin-bottom: 30px;
+  .c3 {
+    margin-bottom: 5px;
   }
 }
 
@@ -411,48 +419,56 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
       name="searchState"
       value="{}"
     />
-    <label
-      class="c1 c2"
-      data-testid="indicator-search-form-input"
+    <div
+      class="c1"
     >
-      <span
-        class="c3"
-      >
-        Search by subject
-      </span>
-      <span
-        class="c4"
+      <label
+        class="c2 c3"
+        data-testid="indicator-search-form-input"
       >
-        <div
-          style="color: rgb(80, 90, 95);"
+        <span
+          class="c4"
         >
-          For example, smoking, diabetes prevalence, or a specific indicator ID
-        </div>
-      </span>
-      <input
-        class="c5"
-        id="indicator"
-        name="indicator"
-        value="indicator"
+          Search by subject
+        </span>
+        <span
+          class="c5"
+        >
+          <div
+            style="color: rgb(80, 90, 95);"
+          >
+            For example, smoking, diabetes prevalence, or a specific indicator ID
+          </div>
+        </span>
+        <input
+          class="c6"
+          id="indicator"
+          maxlength="200"
+          name="indicator"
+          value="indicator"
+        />
+      </label>
+      <div
+        class="c7"
       />
-    </label>
+    </div>
     <div
-      class="c6"
+      class="c8"
     >
       <div
-        class="c7"
+        class="c9"
         data-testid="area-search-input-field"
       >
         <label
-          class="c1"
+          class="c2"
         >
           <span
-            class="c3"
+            class="c4"
           >
             Search by area
           </span>
           <span
-            class="c4"
+            class="c5"
           >
             <div
               style="color: rgb(80, 90, 95);"
@@ -461,7 +477,7 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
             </div>
           </span>
           <input
-            class="c5"
+            class="c6"
             id="areaSearched"
             name="areaSearched"
             value=""
@@ -470,7 +486,7 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
       </div>
     </div>
     <button
-      class="c8"
+      class="c10"
       data-testid="search-form-button-submit"
       style="margin-top: 10px;"
       type="submit"
@@ -478,10 +494,10 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
       Search
     </button>
     <span
-      class="c9"
+      class="c11"
     >
       <svg
-        class="c10"
+        class="c12"
         data-testid="arrow-toggle-button"
         height="30"
         viewBox="0 0 25 25"
@@ -505,7 +521,7 @@ exports[`SearchForm snapshot test - renders the form 1`] = `
         />
       </svg>
       <label
-        class="c1 c11"
+        class="c2 c13"
         fill="#1D70B8"
       >
         Open area filter
diff --git a/frontend/fingertips-frontend/components/forms/SearchForm/index.tsx b/frontend/fingertips-frontend/components/forms/SearchForm/index.tsx
index 3331b328f..2e1ebfead 100644
--- a/frontend/fingertips-frontend/components/forms/SearchForm/index.tsx
+++ b/frontend/fingertips-frontend/components/forms/SearchForm/index.tsx
@@ -1,8 +1,6 @@
 'use client';
 
-import { Button, InputField, H3 } from 'govuk-react';
-import { spacing } from '@govuk-react/lib';
-import styled from 'styled-components';
+import { Button, H3 } from 'govuk-react';
 import { GovukColours } from '@/lib/styleHelpers/colours';
 import { AreaAutoCompleteInputField } from '@/components/molecules/AreaAutoCompleteInputField';
 import { SearchParams } from '@/lib/searchStateManager';
@@ -18,10 +16,8 @@ import { useLoadingState } from '@/context/LoaderContext';
 import { useSearchState } from '@/context/SearchStateContext';
 import React, { useEffect } from 'react';
 import { ArrowExpander } from '@/components/molecules/ArrowExpander';
-
-const StyledInputField = styled(InputField)(
-  spacing.withWhiteSpace({ marginBottom: 6 })
-);
+import { InputField } from '@/components/atoms/InputField';
+import { INDICATOR_SEARCH_MAX_CHARACTERS } from '@/lib/search/indicatorSearchService';
 
 interface SearchFormProps {
   formState: SearchFormState;
@@ -61,7 +57,9 @@ export const SearchForm = ({
         defaultValue={JSON.stringify(searchState)}
         hidden
       />
-      <StyledInputField
+      <InputField
+        characterLimit={INDICATOR_SEARCH_MAX_CHARACTERS}
+        thresholdPercentage={75}
         onKeyDown={(e) => {
           if (e.code === 'Enter') {
             e.preventDefault();
@@ -85,7 +83,7 @@ export const SearchForm = ({
         data-testid="indicator-search-form-input"
       >
         Search by subject
-      </StyledInputField>
+      </InputField>
       <AreaAutoCompleteInputField
         key={`area-auto-complete-${JSON.stringify(searchState)}`}
         inputFieldErrorStatus={!!formState.message}
diff --git a/frontend/fingertips-frontend/lib/search/indicatorSearchService.test.ts b/frontend/fingertips-frontend/lib/search/indicatorSearchService.test.ts
index 2c8e0e111..5c74bd2ef 100644
--- a/frontend/fingertips-frontend/lib/search/indicatorSearchService.test.ts
+++ b/frontend/fingertips-frontend/lib/search/indicatorSearchService.test.ts
@@ -1,4 +1,7 @@
-import { IndicatorSearchService } from './indicatorSearchService';
+import {
+  INDICATOR_SEARCH_MAX_CHARACTERS,
+  IndicatorSearchService,
+} from './indicatorSearchService';
 import { AzureKeyCredential, SearchClient } from '@azure/search-documents';
 import { SearchServiceFactory } from './searchServiceFactory';
 import { INDICATOR_SEARCH_INDEX_NAME } from './searchTypes';
@@ -114,6 +117,21 @@ describe('IndicatorSearchService', () => {
       );
     });
 
+    it('should trim search terms of over 200 characters and report an error', async () => {
+      const searchTerm = '1'.repeat(INDICATOR_SEARCH_MAX_CHARACTERS + 1);
+
+      const searchService = SearchServiceFactory.getIndicatorSearchService();
+      await searchService.searchWith(searchTerm, false);
+
+      expect(mockSearch).toHaveBeenLastCalledWith(
+        '1'.repeat(INDICATOR_SEARCH_MAX_CHARACTERS),
+        expect.objectContaining({
+          queryType: 'full',
+          includeTotalCount: true,
+        })
+      );
+    });
+
     it('should search with correct parameters for single area search', async () => {
       const searchTerm = 'test-search';
 
diff --git a/frontend/fingertips-frontend/lib/search/indicatorSearchService.ts b/frontend/fingertips-frontend/lib/search/indicatorSearchService.ts
index 632077a34..35ed7e27b 100644
--- a/frontend/fingertips-frontend/lib/search/indicatorSearchService.ts
+++ b/frontend/fingertips-frontend/lib/search/indicatorSearchService.ts
@@ -13,6 +13,8 @@ import {
 import { IndicatorMapper } from './indicatorMapper';
 import { escapeString } from '../escapeString';
 
+export const INDICATOR_SEARCH_MAX_CHARACTERS = 200;
+
 export class IndicatorSearchService implements IIndicatorSearchService {
   private readonly searchClient: SearchClient<RawIndicatorDocument>;
   private readonly mapper: IndicatorMapper;
@@ -34,6 +36,11 @@ export class IndicatorSearchService implements IIndicatorSearchService {
     isEnglandSelectedAsGroup: boolean,
     areaCodes?: string[]
   ): Promise<IndicatorDocument[]> {
+    const trimmedSearchTerm = searchTerm.slice(
+      0,
+      INDICATOR_SEARCH_MAX_CHARACTERS
+    );
+
     // If the search is not numeric, we prioritise matching the full term
     // and allow fuzzy matching against each word in the search term
     //
@@ -55,7 +62,7 @@ export class IndicatorSearchService implements IIndicatorSearchService {
       return `"${searchTerm}"^2 ${fuzzySearchTerms}`;
     };
 
-    const query = buildSearchQuery(escapeString(searchTerm));
+    const query = buildSearchQuery(escapeString(trimmedSearchTerm));
 
     // This creates an AI Search filter string which should look like
     //  associatedAreaCodes/any(a: a eq 'E09000023' or a eq 'E09000013' or a eq 'E09000025')


Reviews:
user: login: Joe-Hendley
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T14:08:02Z
user: login: stuc-bjss
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T08:02:38Z
user: login: Joe-Hendley
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T10:05:05Z

Review comments:
diff_hunk: @@ -13,6 +13,8 @@ import {
 import { IndicatorMapper } from './indicatorMapper';
 import { escapeString } from '../escapeString';
 
+export const INDICATOR_SEARCH_MAX_CHARACTERS = 200;
path: frontend/fingertips-frontend/lib/search/indicatorSearchService.ts
user: login: Joe-Hendley
body: this might have a better home - searchTypes.ts maybe?
created_at: 2025-05-21T14:08:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,59 @@
+import { InputField as GovukInputField } from 'govuk-react';
+import { InputFieldProps as GovukInputFieldProps } from '@govuk-react/input-field';
+import { ChangeEventHandler, useState } from 'react';
+import { spacing } from '@govuk-react/lib';
+import styled from 'styled-components';
+import { CharacterCount } from '../CharacterCount';
+
+interface InputFieldProps extends GovukInputFieldProps {
+  characterLimit?: number;
+  thresholdPercentage?: number;
+}
+
+const StyledInputField = styled(GovukInputField)(
+  spacing.withWhiteSpace({ marginBottom: 1 })
+);
+
+const StyledPositionDiv = styled.div({
+  position: 'absolute',
+});
+
+const StyledPaddingDiv = styled.div({ paddingBottom: '30px' });
+
+export function InputField(props: Readonly<InputFieldProps>) {
+  const [inputTextLength, setInputTextLength] = useState(
path: frontend/fingertips-frontend/components/atoms/InputField/index.tsx
user: login: stuc-bjss
body: ```suggestion
export function InputField(props: Readonly<InputFieldProps>) {
  const { characterLimit, thresholdPercentage, input} = props;
  const [inputTextLength, setInputTextLength] = useState(
```
Would prefer to have props destructured at the start of the component
created_at: 2025-05-22T07:47:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,6 +36,15 @@ export class IndicatorSearchService implements IIndicatorSearchService {
     isEnglandSelectedAsGroup: boolean,
     areaCodes?: string[]
   ): Promise<IndicatorDocument[]> {
+    if (searchTerm.length > INDICATOR_SEARCH_MAX_CHARACTERS) {
+      console.error(`search term exceeds expected length`);
+    }
path: frontend/fingertips-frontend/lib/search/indicatorSearchService.ts
user: login: stuc-bjss
body: should we leave this in?
created_at: 2025-05-22T07:56:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,6 +36,15 @@ export class IndicatorSearchService implements IIndicatorSearchService {
     isEnglandSelectedAsGroup: boolean,
     areaCodes?: string[]
   ): Promise<IndicatorDocument[]> {
+    if (searchTerm.length > INDICATOR_SEARCH_MAX_CHARACTERS) {
+      console.error(`search term exceeds expected length`);
+    }
path: frontend/fingertips-frontend/lib/search/indicatorSearchService.ts
user: login: Joe-Hendley
body: removed
created_at: 2025-05-22T10:05:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-23T08:39:10Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=dhsc-govuk_FingertipsNext&pullRequest=589) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=dhsc-govuk_FingertipsNext&pullRequest=589&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=dhsc-govuk_FingertipsNext&pullRequest=589&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=dhsc-govuk_FingertipsNext&pullRequest=589&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=dhsc-govuk_FingertipsNext&pullRequest=589&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=dhsc-govuk_FingertipsNext&pullRequest=589&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=dhsc-govuk_FingertipsNext&pullRequest=589)


author_association: NONE