Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 22 additions & 21 deletions packages/apps/esm-login-app/src/login.resource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import useSwrInfinite, { type SWRInfiniteResponse } from 'swr/infinite';
import useSwrImmutable from 'swr/immutable';
import {
Expand All @@ -10,32 +9,39 @@ import {
type FetchResponse,
type Session,
useDebounce,
useSession,
} from '@openmrs/esm-framework';
import type { LocationEntry, LocationResponse } from './types';

// "swr/infinite" doesn't export InfiniteKeyedMutator directly
type InfiniteKeyedMutator<T> = SWRInfiniteResponse<T extends (infer I)[] ? I : T>['mutate'];

interface LoginLocationData {
error: Error;
error: Error | undefined;
hasMore: boolean;
isLoading: boolean;
loadingNewData: boolean;
locations: Array<LocationEntry>;
locations: Array<LocationEntry> | null;
mutate: InfiniteKeyedMutator<FetchResponse<LocationResponse>[]>;
setPage: (size: number | ((_size: number) => number)) => Promise<FetchResponse<LocationResponse>[]>;
totalResults: number;
totalResults: number | null;
}

export function useLoginLocations(
count: number = 0,
searchQuery: string = '',
useLoginLocationTag: boolean,
): LoginLocationData {
const { t } = useTranslation();
const { user } = useSession();
const userUuid = user?.uuid;
const debouncedSearchQuery = useDebounce(searchQuery);

function constructUrl(page: number, prevPageData: FetchResponse<LocationResponse>) {
// Wait until the user UUID is available before making any request.
if (!userUuid) {
return null;
}

if (prevPageData) {
const nextLink = prevPageData.data?.link?.find((link) => link.relation === 'next');

Expand All @@ -56,27 +62,22 @@ export function useLoginLocations(
).toString();
}

let url = `${fhirBaseUrl}/Location?`;
let urlSearchParameters = new URLSearchParams();
urlSearchParameters.append('_summary', 'data');

if (count) {
urlSearchParameters.append('_count', '' + count);
}

if (page) {
urlSearchParameters.append('_getpagesoffset', '' + page * count);
}
// Use the user-scoped REST endpoint so the backend filters locations to
// only those assigned to this user (falling back to all Login Locations
// for users with no explicit mappings).
const urlSearchParameters = new URLSearchParams();

if (useLoginLocationTag) {
urlSearchParameters.append('_tag', 'Login Location');
urlSearchParameters.append('tag', 'Login Location');
}

if (typeof debouncedSearchQuery === 'string' && debouncedSearchQuery != '') {
urlSearchParameters.append('name:contains', debouncedSearchQuery);
if (typeof debouncedSearchQuery === 'string' && debouncedSearchQuery !== '') {
urlSearchParameters.append('q', debouncedSearchQuery);
}

return url + urlSearchParameters.toString();
const queryString = urlSearchParameters.toString();
const query = queryString ? '?' + queryString : '';
return `${restBaseUrl}/user/${userUuid}/location${query}`;
}

const { data, isLoading, isValidating, setSize, error, mutate } = useSwrInfinite<
Expand Down Expand Up @@ -144,4 +145,4 @@ export function useValidateLocationUuid(userPreferredLocationUuid: string) {
);

return results;
}
}
113 changes: 113 additions & 0 deletions packages/apps/esm-login-app/src/login/login.resource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { openmrsFetch, useSession, type FetchResponse, type Session } from '@openmrs/esm-framework';
import { SWRConfig } from 'swr';
import { mockLoginLocations } from '../../__mocks__/locations.mock';
import { useLoginLocations } from '../login.resource';

const mockOpenmrsFetch = vi.mocked(openmrsFetch);
const mockUseSession = vi.mocked(useSession);

const userUuid = '90bd24b3-e700-46b0-a5ef-c85afdfededd';

// Provide a fresh SWR cache for every test so results are never served from a
// previous test's cache. Required because useSwrInfinite caches by URL key.
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(SWRConfig, { value: { provider: () => new Map() } }, children);

describe('useLoginLocations', () => {
beforeEach(() => {
mockUseSession.mockReturnValue({
user: { uuid: userUuid, display: 'Test User', userProperties: {} },
authenticated: true,
sessionId: 'test-session-id',
} as Session);

mockOpenmrsFetch.mockResolvedValue(mockLoginLocations as FetchResponse<any>);
});

it('does not fetch until the user UUID is available', async () => {
mockUseSession.mockReturnValue({ authenticated: false, sessionId: '' } as Session);

const { result } = renderHook(() => useLoginLocations(50, '', true), { wrapper });

// isLoading stays false and no fetch is made because constructUrl returns null
expect(result.current.isLoading).toBe(false);
expect(mockOpenmrsFetch).not.toHaveBeenCalled();
});

it('calls the user-scoped REST endpoint with the correct user UUID', async () => {
const { result } = renderHook(() => useLoginLocations(50, '', true), { wrapper });

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

const calledUrl = mockOpenmrsFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain(`/ws/rest/v1/user/${userUuid}/location`);
});

it('appends the tag parameter when useLoginLocationTag is true', async () => {
const { result } = renderHook(() => useLoginLocations(50, '', true), { wrapper });

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

const calledUrl = mockOpenmrsFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('tag=Login+Location');
});

it('does not append the tag parameter when useLoginLocationTag is false', async () => {
const { result } = renderHook(() => useLoginLocations(50, '', false), { wrapper });

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

const calledUrl = mockOpenmrsFetch.mock.calls[0][0] as string;
expect(calledUrl).not.toContain('tag=');
});

it('appends the search query when a search term is provided', async () => {
const { result } = renderHook(() => useLoginLocations(50, 'outpatient', true), { wrapper });

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

const calledUrl = mockOpenmrsFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('q=outpatient');
});

it('does not append a search query when the search term is empty', async () => {
const { result } = renderHook(() => useLoginLocations(50, '', true), { wrapper });

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

const calledUrl = mockOpenmrsFetch.mock.calls[0][0] as string;
expect(calledUrl).not.toContain('q=');
});

it('returns the locations from the response', async () => {
const { result } = renderHook(() => useLoginLocations(50, '', true), { wrapper });

await waitFor(() => {
expect(result.current.locations).not.toBeNull();
});

expect(result.current.locations).toEqual(mockLoginLocations.data.entry);
expect(result.current.totalResults).toBe(4);
});

it('returns null for locations while the user UUID is unavailable', () => {
mockUseSession.mockReturnValue({ authenticated: false, sessionId: '' } as Session);

const { result } = renderHook(() => useLoginLocations(50, '', true), { wrapper });

expect(result.current.locations).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useMemo } from 'react';
import useSwrImmutable from 'swr/immutable';
import useSwrInfinite from 'swr/infinite';
import { type FetchResponse, fhirBaseUrl, openmrsFetch } from '@openmrs/esm-api';
import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-api';
import { type FHIRLocationResource } from '@openmrs/esm-emr-api';
import { useDebounce } from '@openmrs/esm-react-utils';
import { useDebounce, useSession } from '@openmrs/esm-react-utils';

export interface LocationResponse {
type: string;
Expand Down Expand Up @@ -73,8 +73,16 @@ export function useLocationByUuid(locationUuid?: string) {
* @category API
*/
export function useLocations(locationTag?: string, count: number = 0, searchQuery: string = ''): LoginLocationData {
const { user } = useSession();
const userUuid = user?.uuid;
const debouncedSearchQuery = useDebounce(searchQuery);

function constructUrl(page: number, prevPageData: FetchResponse<LocationResponse>) {
// Wait until the user UUID is available before making any request.
if (!userUuid) {
return null;
}

if (prevPageData) {
const nextLink = prevPageData.data?.link?.find((link) => link.relation === 'next');

Expand All @@ -95,27 +103,22 @@ export function useLocations(locationTag?: string, count: number = 0, searchQuer
).toString();
}

let url = `${fhirBaseUrl}/Location?`;
let urlSearchParameters = new URLSearchParams();
urlSearchParameters.append('_summary', 'data');

if (count) {
urlSearchParameters.append('_count', '' + count);
}

if (page) {
urlSearchParameters.append('_getpagesoffset', '' + page * count);
}
// Use the user-scoped REST endpoint so the backend filters locations to
// only those assigned to this user (falling back to all Login Locations
// for users with no explicit mappings).
const urlSearchParameters = new URLSearchParams();

if (locationTag) {
urlSearchParameters.append('_tag', locationTag);
urlSearchParameters.append('tag', locationTag);
}

if (typeof debouncedSearchQuery === 'string' && debouncedSearchQuery !== '') {
urlSearchParameters.append('name:contains', debouncedSearchQuery);
urlSearchParameters.append('q', debouncedSearchQuery);
}

return url + urlSearchParameters.toString();
const queryString = urlSearchParameters.toString();
const query = queryString ? '?' + queryString : '';
return `${restBaseUrl}/user/${userUuid}/location${query}`;
}

const { data, isLoading, isValidating, setSize, error } = useSwrInfinite<FetchResponse<LocationResponse>, Error>(
Expand All @@ -136,4 +139,4 @@ export function useLocations(locationTag?: string, count: number = 0, searchQuer
}, [isLoading, data, isValidating, setSize, error]);

return memoizedLocations;
}
}
Loading