Search

How the search function is built and where it is rooted.

There are currently two search types:

  • Global - the one that searches on all pages where the search is built in.

  • Local - on a specific page and only on the material of that page.

Backend

On the backend you won't find separate module that responsible for search mechanic. Instead, you will find search methods in inside modules, depending on what we are looking for. Local search - it's a regular "findAll" etc. methods which include search queries to DB, depending on various params such as filters, sorters and pagination.

The Global search function is implemented in the following modules:

  • Leads

  • Businesses

  • Users

If talk about Local search, you can find it on this pages:

  • Leads

  • Businesses

  • Users (Super admin and admin roles)

  • Team

Global search method are pretty same in all modules. For example, method from leads.service.ts:

leads.service.ts:
    async search(search: string, user: JwtUserPayload) {
        const sanitizedSearch = sanitizeString(search);

        const userWithMemberships = await this.userRepository.findOne({
            where: { id: user.id },
            relations: ['memberships', 'memberships.team'],
        });

        const memberships = userWithMemberships.memberships.filter(m => m.team).map(m => m.team.id);

        const leadsQuery = this.leadsRepository
            .createQueryBuilder('lead')
            .leftJoinAndSelect('lead.business', 'business')
            .leftJoinAndSelect('lead.team', 'team')
            .leftJoinAndSelect('lead.status', 'status')
            .leftJoin('team.memberships', 'membership')
            .where('lead.isDeleted = false')
            .andWhere(
                new Brackets(qb => {
                    qb.where('lead.firstName ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.lastName ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.state::text ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('status.name::text ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.source ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere("ARRAY_TO_STRING(lead.phoneNumber, ',') ILIKE :search", {
                            search: `%${sanitizedSearch}%`,
                        })
                        .orWhere('lead.email::text ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.description ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.location ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.position ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('business.name ILIKE :search', { search: `%${sanitizedSearch}%` });
                })
            );

        if (!user.roles.includes(Role.Admin) && !user.roles.includes(Role.SuperAdmin)) {
            leadsQuery.andWhere('team.id IN (:...memberships)', { memberships });
        }

        const leads = await leadsQuery.getMany();

        return leads;
    }

N.B. All search methods used sanitized search string which clear out all unnecessary symbols from the search string:

sanitize.ts
export default function sanitizeString(str) {
    return str.replace(/[&<>"']/g, function (match) {
        const escape = {
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#039;',
        };
        return escape[match];
    });
}

This utility you will find in api/common/helpers/

N.B. Also such methods must use queryBuilder and Brackets from TypeORM package which are very important to use. It's prevent bugs to occur.


"FindAll" method with search query: (same leads service)

leads.service.ts
    async findAll({ limit, offset, search, business, state, status, sortField, sortOrder, isDeleted }, teamId) {
        const queryBuilder = this.leadsRepository
            .createQueryBuilder('lead')
            .leftJoinAndSelect('lead.business', 'business')
            .leftJoinAndSelect('lead.status', 'status')
            .andWhere('lead.teamId = :teamId', { teamId })
            .andWhere('lead.isDeleted = :isDeleted', { isDeleted })
            .withDeleted();

        if (search) {
            const sanitizedSearch = sanitizeString(search);
            queryBuilder.andWhere(
                new Brackets(qb => {
                    qb.where(`CONCAT(lead.firstName, ' ', lead.lastName) ILIKE :fullName`, {
                        fullName: `%${sanitizedSearch}%`,
                    })
                        .orWhere('lead.state::text ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('status.name::text ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.source ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere("ARRAY_TO_STRING(lead.phoneNumber, ',') ILIKE :search", {
                            search: `%${sanitizedSearch}%`,
                        })
                        .orWhere('lead.email::text ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.description ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.location ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('lead.position ILIKE :search', { search: `%${sanitizedSearch}%` })
                        .orWhere('business.name ILIKE :search', { search: `%${sanitizedSearch}%` });
                })
            );
        }

        if (business) {
            if (business.includes('No business')) {
                queryBuilder.andWhere('(business.name IN (:...business) OR business.name IS NULL)', { business });
            } else {
                queryBuilder.andWhere('business.name IN (:...business)', { business });
            }
        }

        if (state) {
            queryBuilder.andWhere('lead.state IN (:...state)', { state });
        }

        if (status) {
            queryBuilder.andWhere('lead.statusId IN (:...status)', { status });
        }

        if (sortField && sortOrder) {
            queryBuilder.orderBy(`lead.${sortField}`, sortOrder);
            const [sortedLeads, leadsCount] = await queryBuilder.skip(offset).take(limit).getManyAndCount();

            return { count: leadsCount, leads: sortedLeads };
        }

        const [list, count] = await queryBuilder.skip(offset).take(limit).getManyAndCount();

        return { count, leads: list };
    }

N.B. Please notice how filters and sorter from frontend are used here

All Global Search Methods use Guard which will limit the number of requests from a single address. This is done to protect against excessive requests to the server and to avoid unforeseen errors. This protection will send us an error if the number of search queries reaches the limit.

If we talk about Guard , then it is still used not only on search methods. Everything is done with the same purpose.

Controller with Guard:

leads.controller.ts
    @Get('/search')
    @UseGuards(JwtAuthenticationGuard, new RateLimitGuard(), AbilitiesGuard)
    @CheckAbilities([Actions.read, LeadsCasl])
    search(@Query() searchParams: SearchLeadDto, @GetUser() user: JwtUserPayload) {
        const { search } = searchParams;

        return this.leadsService.search(search, user);
    }

Guard function:

rate-limit.guard.ts
@Injectable()
export class RateLimitGuard implements CanActivate {
    private limiter;

    constructor(
        private readonly windowMs: number = 60 * 1000, // 1 minute
        private readonly max: number = 10 // 10 requests per windowMs
    ) {
        this.limiter = rateLimit({
            windowMs: this.windowMs,
            max: this.max,
            message: 'Too many requests, please try again later.',
        });
    }

    canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
        const req = context.switchToHttp().getRequest();
        const res = context.switchToHttp().getResponse();
        return new Promise((resolve, reject) => {
            this.limiter(req, res, err => {
                if (err) {
                    reject(new HttpException(err.message, HttpStatus.TOO_MANY_REQUESTS));
                } else {
                    resolve(true);
                }
            });
        });
    

Here you can configure how many requests can pass during how long:

private readonly windowMs: number = 60 * 1000, // 1 minute
private readonly max: number = 10 // 10 requests per windowMs

N.B. You should be noted that this method basically uses a package (version which is used in the time when search functionality was made):

"express-rate-limit": "^7.4.0",

Frontend

This part is made within several global components, which ends with one defined. They lie along the following path:

.../ui/common/components/GlobalSearch/

There you can find one "global" component that stores the input and various modal windows that are responsible for finding and displaying the result.

If we talk about the search on the pages - there will be used only the input that is used in the Global Search.

Let's look at certain features of the search.

A service that processes a search request to the backend: (.../ui/serivces/globalSearchService.js)

globalSearchService.js
export const useGlobalSearchService = () => {
    const { axios } = useAxios();

    const globalSearch = async query => {
        let results = {};
        await Promise.all([
            axios.get(`/leads/search`, { params: { search: query } }).then(response => {
                results.leads = response.data;
            }),
            axios.get(`/businesses/search`, { params: { search: query } }).then(response => {
                results.businesses = response.data;
            }),
            axios.get(`/users/search`, { params: { search: query } }).then(response => {
                results.users = response.data;
            }),
        ]).catch(err => {
            console.error(err); // just log the error. Axios service will show notification.
        });

        return results;
    };

    return {
        globalSearch,
    };
};

Since we have search methods lie each in its own module, then the query here is formed a little in its own way, all understandable.

Search function in the main component:

GlobalSearch.js
    const onSearch = searchText => {
        setLoading(true);
        debounce(
            () =>
                globalSearch(searchText)
                    .then(response => {
                        setResults({ leads: response.leads, businesses: response.businesses, users: response.users });
                    })
                    .finally(() => {
                        setLoading(false);
                        setOpenAllResults(true);
                    }),
            500
        )();
    };

N.B. Please note that this feature also uses the debouce package for additional protection against excessive requests.

Search Input are written a bit different in Global Search and in the pages.

Global Search component:

GlobalSearch.js
<SearchInput
                loading={loading}
                onSearch={onSearch}
                searchText={searchText}
                setSearchText={setSearchText}
                style={{
                    width: isSmallDevice ? '100%' : '300px',
                    backgroundColor: isSmallDevice ? backgroundColor.page : 'inherit',
                }}
            />

One that is used in Leads Page:

Leads.js
<SearchInput
    placeholder={t('search')}
    value={searchText}
    style={{ width: isMediumDevice ? 220 : 250 }}
    onChange={e => {
        setSearchText(e.target.value);
        if (e.target.value === '') {
                handleResetSearch();
        }
    }}
    onPressEnter={e => {
        e.preventDefault();
        handleSearchLead(searchText);
    }}
    prefix={<SearchIcon onClick={() => handleSearchLead(searchText)} />}
    suffix={
    searchText && (
        <ClearInputIcon
            setSearchText={setSearchText}
            handleReset={handleResetSearch}
        />
        )
    }
/>

The difference is not big, but it has its own specifics depending on page.

N.B. The result on the pages that have their own, Local, search, will be displayed in the table.

Last updated