Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/csm-portal/webapp/src/api/backend/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export function uiAttachmentFromBe(att: BeAttachment): CaseAttachment {
filename: att.name,
size: att.sizeBytes,
contentType: att.type,
uploadedBy: att.createdBy || "Unknown",
uploadedBy: att.createdBy.name?.trim() || att.createdBy.email?.trim() || "Unknown",
uploadedAt: att.createdOn,
};
}
14 changes: 12 additions & 2 deletions apps/csm-portal/webapp/src/api/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,15 @@ export interface BeEntityRef {
}

/** A referenced case carrying only its display number, e.g. the related case. */
export type BeParentCaseType = "case" | "incident" | "change_request" | "problem";

export interface BeCaseNumberRef {
id: string;
number?: string;
/** Only populated on `parentCase`, since a case's parent can be any of these
* task-derived record types; absent/undefined elsewhere (e.g. `relatedCase`,
* always another case). */
type?: BeParentCaseType | null;
}

/**
Expand Down Expand Up @@ -905,7 +911,8 @@ export interface BeAttachment {
type: string;
sizeBytes: number;
description?: string | null;
createdBy: string;
/** Uploader, in the same `{ id, name, email }` shape used elsewhere (e.g. case `createdBy`). */
createdBy: BeUserRef;
createdOn: string;
downloadUrl?: string | null;
previewUrl?: string | null;
Expand Down Expand Up @@ -1045,7 +1052,8 @@ export type BeSubscriptionType =

export interface BeProject {
id: string;
accountId?: string;
/** Nested on the wire (ServiceNow data source); absent when the project has no linked account. */
account?: { id: string; name: string };
sfId?: string;
name?: string;
projectKey?: string;
Expand All @@ -1059,6 +1067,8 @@ export interface BeProject {
export interface BeProjectSearchPayload {
pagination?: BePagination;
searchQuery?: string;
/** Filter to projects belonging to this account (ServiceNow data source only). */
accountId?: string;
}

export interface BeProjectSearchResponse extends BeSearchResponseBase {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ interface AttachmentsFieldProps {
maxEncodedBytes: number;
/** Adjusts the hint text only ("At least one required" vs "Optional"). */
required?: boolean;
/** Disables adding/removing files, e.g. while the parent form is submitting. */
disabled?: boolean;
}

/**
Expand All @@ -47,6 +49,7 @@ export default function AttachmentsField({
onError,
maxEncodedBytes,
required = false,
disabled = false,
}: AttachmentsFieldProps): JSX.Element {
const fileInputRef = useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -98,13 +101,15 @@ export default function AttachmentsField({
variant="outlined"
size="small"
startIcon={<Upload size={16} />}
disabled={disabled}
>
Add attachments
<input
ref={fileInputRef}
type="file"
multiple
hidden
disabled={disabled}
onChange={(e) => void onAddFiles(e)}
/>
</Button>
Expand Down Expand Up @@ -135,7 +140,12 @@ export default function AttachmentsField({
<Typography variant="caption" color="text.secondary">
{formatBytes(a.size)}
</Typography>
<IconButton size="small" aria-label={`Remove ${a.name}`} onClick={() => removeAt(i)}>
<IconButton
size="small"
aria-label={`Remove ${a.name}`}
onClick={() => removeAt(i)}
disabled={disabled}
>
<X size={16} />
</IconButton>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type { Project, SearchProjectsResponse } from "@features/csm-projects/typ
// useAccountProjects.ts reads window.config at module load (via
// @config/apiConfig) and calls the real Asgardeo-backed useAuthApiClient;
// neither is present under vitest, so mock both. authFetchMock stands in for
// the fetch wrapper and is what the hook actually calls per page.
// the fetch wrapper and is what the hook actually calls.
const authFetchMock = vi.fn();
vi.mock("@config/apiConfig", () => ({
apiConfig: { backendUrl: "https://example.test" },
Expand All @@ -34,22 +34,17 @@ vi.mock("@hooks/useAuthApiClient", () => ({

import { useAccountProjects } from "@features/csm-accounts/api/useAccountProjects";

function project(id: string, accountId: string | undefined): Project {
function project(id: string, accountId: string): Project {
return {
id,
name: `Project ${id}`,
key: id.toUpperCase(),
accountId,
account: { id: accountId, name: `Account ${accountId}` },
subscriptionType: undefined,
endDate: undefined,
} as unknown as Project;
}

function okResponse(body: Pick<SearchProjectsResponse, "projects" | "hasMore">): Response {
const full: SearchProjectsResponse = { ...body, total: body.projects.length, limit: 100, offset: 0 };
return jsonResponse(full);
}

function jsonResponse(body: SearchProjectsResponse): Response {
return {
ok: true,
Expand All @@ -74,10 +69,13 @@ describe("useAccountProjects", () => {
authFetchMock.mockReset();
});

it("filters to the given account and reports the filter as supported when accountId is populated", async () => {
it("sends the accountId filter and returns the server-filtered projects", async () => {
authFetchMock.mockResolvedValueOnce(
okResponse({
projects: [project("p1", "acc-1"), project("p2", "acc-2")],
jsonResponse({
projects: [project("p1", "acc-1")],
total: 1,
limit: 100,
offset: 0,
hasMore: false,
}),
);
Expand All @@ -87,57 +85,26 @@ describe("useAccountProjects", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.projects.map((p) => p.id)).toEqual(["p1"]);
expect(result.current.data?.isFilterSupported).toBe(true);
});

// The ServiceNow data source's project-search API never sets `accountId`
// on any result row. In that case an empty `projects` array must NOT be
// presented as a confirmed "no projects" result -- `isFilterSupported` is
// what lets the page distinguish the two.
it("reports the filter as unsupported when every scanned project omits accountId", async () => {
authFetchMock.mockResolvedValueOnce(
okResponse({
projects: [project("p1", undefined), project("p2", undefined)],
hasMore: false,
}),
);

const { result } = renderHook(() => useAccountProjects("acc-1"), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.projects).toEqual([]);
expect(result.current.data?.isFilterSupported).toBe(false);
const [, requestInit] = authFetchMock.mock.calls[0];
expect(JSON.parse(requestInit.body as string)).toMatchObject({
accountId: "acc-1",
});
});

it("treats an empty catalogue as vacuously supported", async () => {
it("returns an empty list when the account has no projects", async () => {
authFetchMock.mockResolvedValueOnce(
okResponse({ projects: [], hasMore: false }),
jsonResponse({ projects: [], total: 0, limit: 100, offset: 0, hasMore: false }),
);

const { result } = renderHook(() => useAccountProjects("acc-1"), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.projects).toEqual([]);
expect(result.current.data?.isFilterSupported).toBe(true);
});

it("still reports the filter as supported when only some pages/rows carry accountId", async () => {
// Postgres data source: a genuine miss on this account, but accountId IS
// populated elsewhere in the scan, so the empty result is trustworthy.
authFetchMock.mockResolvedValueOnce(
okResponse({
projects: [project("p1", "acc-2"), project("p2", "acc-3")],
hasMore: false,
}),
);

const { result } = renderHook(() => useAccountProjects("acc-1"), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.projects).toEqual([]);
expect(result.current.data?.isFilterSupported).toBe(true);
it("does not fetch when accountId is undefined", () => {
renderHook(() => useAccountProjects(undefined), { wrapper });
expect(authFetchMock).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,103 +25,42 @@ import type {
SearchProjectsResponse,
} from "@features/csm-projects/types/csmProjects";

// `POST /projects/search` has no `accountId` filter (neither the backend nor
// the ServiceNow-backed project-search API it currently proxies supports
// filtering by account — confirmed against the ServiceNow DEV tenant's
// `ProjectsAPI`/`ProjectUtils` scripted API, which only takes
// searchQuery/closureStatus/endDateFrom/endDateTo/sortBy/sortOrder). So this
// scans the project catalogue a page at a time and filters by `accountId`
// client-side, mirroring the existing full-catalogue scan in
// `useProjectOptions` (same MAX_PAGES cap, same ~2,000-project ceiling).
//
// KNOWN GAP: `Project.accountId` is only populated end-to-end on the Postgres
// data source today. The ServiceNow adapter (`sn_project_service.go`) never
// sets it, because the ServiceNow project-search scripted API's list/search
// response omits the (already-loaded, no-extra-query) `account` reference
// field — it is only included in the single-project detail response. Until
// that gap is closed (an additive ServiceNow scripted-API change plus a
// matching `sn_project_service.go` mapping change — both outside this
// frontend's scope), this hook would return an empty list for every account
// on the ServiceNow data source, indistinguishable from a real "no projects"
// result. `isFilterSupported` below exists so callers can tell the two apart
// and avoid rendering a misleadingly authoritative empty state.
//
// NOTE: MAX_PAGES caps the scan at ~2,000 projects (40 pages * BE_MAX_PAGE_LIMIT).
// On an account whose true project belongs to a catalogue larger than that,
// the scan can still miss a real match — this is a pre-existing limitation
// (shared with `useProjectOptions`) and out of scope for this fix; a full
// pagination UI would be needed to close it. `isFilterSupported` is unaffected
// by this cap: it only reflects whether `accountId` was populated on the pages
// we *did* see, not whether the scan was exhaustive, so a cap-truncated scan
// must never be treated as proof the filter is (or isn't) supported.
const MAX_PAGES = 40;

export interface AccountProjectsResult {
/** Projects matched to this account across the scanned pages. */
projects: Project[];
/**
* Whether the underlying data source actually populates `accountId` on
* project-search results. `false` means every project seen across the scan
* omitted `accountId` (the current ServiceNow behavior) — in that case
* `projects` being empty tells us nothing about whether the account has
* projects, and callers must not render it as a confirmed "no projects"
* result. `true` (including the vacuous case where the whole catalogue is
* empty) means the filter is trustworthy.
*/
isFilterSupported: boolean;
}

/**
* All projects belonging to a given account, for the Account detail page's
* Projects section. Client-side filtered — see the module comment above for
* why, and its current-data-source limitation.
* Projects belonging to a given account, for the Account detail page's
* Projects section. `POST /projects/search` supports a server-side
* `accountId` filter, so this is a single filtered request rather than a
* client-side scan-and-filter of the whole catalogue.
*
* Only the first `BE_MAX_PAGE_LIMIT` projects are returned — this section has
* no pager of its own today; an account with more projects than that would
* need one added.
*/
export function useAccountProjects(
accountId: string | undefined,
): UseQueryResult<AccountProjectsResult, Error> {
): UseQueryResult<{ projects: Project[] }, Error> {
const authFetch = useAuthApiClient();

return useQuery<AccountProjectsResult, Error>({
return useQuery<{ projects: Project[] }, Error>({
queryKey: [ApiQueryKeys.CSM_ACCOUNT_PROJECTS, accountId ?? ""],
queryFn: async (): Promise<AccountProjectsResult> => {
const matches: Project[] = [];
let sawAnyProject = false;
let sawAnyAccountId = false;
let offset = 0;

for (let page = 0; page < MAX_PAGES; page += 1) {
const request: SearchProjectsRequest = {
pagination: { limit: BE_MAX_PAGE_LIMIT, offset },
};
const res = await authFetch(`${apiConfig.backendUrl}/projects/search`, {
method: "POST",
body: JSON.stringify(request),
});
if (!res.ok) {
const body = await res.text();
throw new ApiError(
res.status,
res.statusText,
parseApiResponseMessage(body, res.status, res.statusText),
);
}
const data = (await res.json()) as SearchProjectsResponse;
const projects = data.projects ?? [];
if (projects.length > 0) {
sawAnyProject = true;
if (projects.some((p) => p.accountId !== undefined)) sawAnyAccountId = true;
}
matches.push(...projects.filter((p) => p.accountId === accountId));

if (!data.hasMore || projects.length === 0) break;
offset += projects.length;
queryFn: async (): Promise<{ projects: Project[] }> => {
const request: SearchProjectsRequest = {
accountId,
pagination: { limit: BE_MAX_PAGE_LIMIT, offset: 0 },
};
const res = await authFetch(`${apiConfig.backendUrl}/projects/search`, {
method: "POST",
body: JSON.stringify(request),
});
if (!res.ok) {
const body = await res.text();
throw new ApiError(
res.status,
res.statusText,
parseApiResponseMessage(body, res.status, res.statusText),
);
}

// Vacuously "supported" when the catalogue itself is empty — there is
// nothing to contradict trustworthiness in that case.
const isFilterSupported = !sawAnyProject || sawAnyAccountId;

return { projects: matches, isFilterSupported };
const data = (await res.json()) as SearchProjectsResponse;
return { projects: data.projects ?? [] };
},
enabled: !!accountId,
staleTime: 30_000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,12 @@ function BackButton({ onClick }: { onClick: () => void }): JSX.Element {
}

/**
* Projects belonging to this account. Filtered client-side (see
* `useAccountProjects`) since `POST /projects/search` has no account filter —
* confirmed against the ServiceNow scripted API this endpoint proxies today.
* On the ServiceNow data source `accountId` is never populated on results, so
* `useAccountProjects` flags that via `isFilterSupported: false` and this
* section renders a distinct "not available" message instead of implying a
* confirmed empty result.
* Projects belonging to this account, server-side filtered via
* `POST /projects/search`'s `accountId` filter (see `useAccountProjects`).
*/
function ProjectsSection({ accountId }: { accountId: string }): JSX.Element {
const { data, isLoading, isError, error } = useAccountProjects(accountId);
const projects = data?.projects ?? [];
const isFilterSupported = data?.isFilterSupported ?? true;

return (
<Card sx={{ p: 2.5, display: "flex", flexDirection: "column", gap: 2 }}>
Expand Down Expand Up @@ -142,15 +136,6 @@ function ProjectsSection({ accountId }: { accountId: string }): JSX.Element {
/>
</TableCell>
</TableRow>
) : !isFilterSupported ? (
<TableRow>
<TableCell colSpan={4} align="center" sx={{ py: 4 }}>
<Typography variant="body2" color="text.secondary">
Project list isn&apos;t available for the ServiceNow data
source yet.
</Typography>
</TableCell>
</TableRow>
) : projects.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center" sx={{ py: 4 }}>
Expand Down
Loading