diff --git a/messages/de.json b/messages/de.json index cccc2d076..f06d2b896 100644 --- a/messages/de.json +++ b/messages/de.json @@ -774,6 +774,7 @@ "Leave a comment on your moderation request": "Hinterlassen Sie einen Kommentar zu Ihrer Moderationsanfrage ...", "Legal Evaluation": "Rechtliche Bewertung", "Legal Notice": "Rechtliche Hinweise", + "Level": "Ebene", "Library": "Bibliothek", "Licence names": "Lizenznamen", "License": "Lizenz", diff --git a/messages/en.json b/messages/en.json index 1e4b070c4..5eab0924d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -702,6 +702,7 @@ "IN_QUEUE": "In Queue", "IS USED BY THE FOLLOWING COMPONENTS": "IS USED BY THE FOLLOWING COMPONENTS", "Id": "Id", + "Level": "Level", "If the wrong SPDX is entered, the information will not be registered correctly": "If the wrong SPDX is entered, the information will not be registered correctly", "Impact": "Impact", "Import": "Import", @@ -950,6 +951,7 @@ "Obligation updated successfully": "Obligation updated successfully", "Obligations": "Obligations", "Obligations View": "Obligations View", + "All Obligations": "All Obligations", "On Hold": "On Hold", "Only Approved": "Only Approved", "Open": "Open", diff --git a/messages/es.json b/messages/es.json index 141ca1982..3074a3176 100644 --- a/messages/es.json +++ b/messages/es.json @@ -774,6 +774,7 @@ "Leave a comment on your moderation request": "Deje un comentario sobre su solicitud de moderación ...", "Legal Evaluation": "Evaluación jurídica", "Legal Notice": "Aviso legal", + "Level": "Nivel", "Library": "Biblioteca", "Licence names": "Nombres de licencia", "License": "Licencia", diff --git a/messages/fr.json b/messages/fr.json index 9a55d8359..802fdbaa6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -774,6 +774,7 @@ "Leave a comment on your moderation request": "Laissez un commentaire sur votre demande de modération ...", "Legal Evaluation": "Évaluation juridique", "Legal Notice": "Avis juridique", + "Level": "Niveau", "Library": "Bibliothèque", "Licence names": "Noms des licences", "License": "Licence", diff --git a/messages/ja.json b/messages/ja.json index 8c95a919f..4fb251bd2 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -774,6 +774,7 @@ "Leave a comment on your moderation request": "モデレートリクエストにコメントを残してください...", "Legal Evaluation": "法的評価", "Legal Notice": "法的通知", + "Level": "レベル", "Library": "図書館", "Licence names": "ライセンス名", "License": "ライセンス", diff --git a/messages/ko.json b/messages/ko.json index 2a33d6b4c..2be9dd9af 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -774,6 +774,7 @@ "Leave a comment on your moderation request": "중재 요청에 의견을 남겨주세요 ...", "Legal Evaluation": "법률 평가", "Legal Notice": "법적 고지", + "Level": "레벨", "Library": "도서관", "Licence names": "Licence 이름", "License": "특허", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index f7287bf0a..35e180282 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -774,6 +774,7 @@ "Leave a comment on your moderation request": "Deixe um comentário sobre sua solicitação de moderação ...", "Legal Evaluation": "Avaliação Jurídica", "Legal Notice": "Aviso legal", + "Level": "Nível", "Library": "Biblioteca", "Licence names": "Nomes das licenças", "License": "Licença", diff --git a/messages/vi.json b/messages/vi.json index e830f876d..1e5b1bae5 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -774,6 +774,7 @@ "Leave a comment on your moderation request": "Để lại nhận xét về yêu cầu kiểm duyệt của bạn ...", "Legal Evaluation": "Đánh giá pháp lý", "Legal Notice": "Thông báo pháp lý", + "Level": "Cấp độ", "Library": "Thư viện", "Licence names": "Tên giấy phép", "License": "Giấy phép", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 111523bd1..54a6a0bc9 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -774,6 +774,7 @@ "Leave a comment on your moderation request": "对您的审核请求发表评论...", "Legal Evaluation": "法律评估", "Legal Notice": "法律声明", + "Level": "级别", "Library": "图书馆", "Licence names": "许可证名称", "License": "执照", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index a2304c3b7..01adf72f0 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -774,6 +774,7 @@ "Leave a comment on your moderation request": "對您的審核請求發表評論...", "Legal Evaluation": "法律评估", "Legal Notice": "法律通知", + "Level": "等級", "Library": "圖書館", "Licence names": "授權名稱", "License": "執照", diff --git a/src/app/[locale]/projects/components/Obligations/AllObligationsView.tsx b/src/app/[locale]/projects/components/Obligations/AllObligationsView.tsx new file mode 100644 index 000000000..63c27274a --- /dev/null +++ b/src/app/[locale]/projects/components/Obligations/AllObligationsView.tsx @@ -0,0 +1,324 @@ +// Copyright (C) Anushree Bondia, 2026. Part of the SW360 Frontend Project. + +// This program and the accompanying materials are made +// available under the terms of the Eclipse Public License 2.0 +// which is available at https://www.eclipse.org/legal/epl-2.0/ + +// SPDX-License-Identifier: EPL-2.0 +// License-Filename: LICENSE + +'use client' + +import { ColumnDef, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table' +import { StatusCodes } from 'http-status-codes' +import { signOut, useSession } from 'next-auth/react' +import { useTranslations } from 'next-intl' +import { type JSX, useEffect, useMemo, useState } from 'react' +import { Spinner } from 'react-bootstrap' + +import { ClientSidePageSizeSelector, ClientSideTableFooter, SW360Table } from '@/components/sw360' +import { Embedded, ObligationData, ObligationResponse, Project } from '@/object-types' +import { ApiUtils } from '@/utils' + +interface ProjectPathInfo { + id: string + projectPath: string +} + +interface AggregatedObligation extends ObligationData { + projectId: string + obligationTitle: string + projectPath: string +} + +type LinkedProjects = Embedded + +interface Props { + projectId: string +} + +const Capitalize = (text: string) => + text + .split('_') + .map((c) => c.charAt(0).toUpperCase() + c.substring(1).toLowerCase()) + .join(' ') + +const extractAllProjects = (projects: Project[], parentPath: string[], allProjectsFlat: ProjectPathInfo[]) => { + for (const p of projects) { + const id = p.id || p._links?.self.href.split('/').at(-1) || '' + const version = p.version ? ` (${p.version})` : '' + const thisPath = [ + ...parentPath, + p.name + version, + ] + allProjectsFlat.push({ + id, + projectPath: thisPath.join(' -> '), + }) + const linkedProjects = p._embedded?.['sw360:linkedProjects'] + if (linkedProjects && linkedProjects.length > 0) { + extractAllProjects(linkedProjects, thisPath, allProjectsFlat) + } + } +} + +export default function AllObligationsView({ projectId }: Props): JSX.Element { + const t = useTranslations('default') + const session = useSession() + const [showProcessing, setShowProcessing] = useState(false) + const [aggregatedObligations, setAggregatedObligations] = useState([]) + + useEffect(() => { + if (session.status === 'unauthenticated') { + void signOut() + } + }, [ + session, + ]) + + const columns = useMemo[]>( + () => [ + { + id: 'project', + header: t('Project'), + cell: ({ row }) => {row.original.projectPath}, + meta: { + width: '20%', + }, + }, + { + id: 'title', + header: t('Obligation'), + accessorKey: 'obligationTitle', + meta: { + width: '25%', + }, + }, + { + id: 'status', + header: t('Status'), + cell: ({ row }) => <>{Capitalize(row.original.status ?? '')}, + meta: { + width: '15%', + }, + }, + { + id: 'type', + header: t('Type'), + cell: ({ row }) => <>{Capitalize(row.original.obligationType ?? '')}, + meta: { + width: '10%', + }, + }, + { + id: 'level', + header: t('Level'), // translation key: Level + cell: ({ row }) => <>{Capitalize(row.original.obligationLevel ?? '')}, + meta: { + width: '10%', + }, + }, + { + id: 'id', + header: t('Id'), // translation key: Id + accessorKey: 'id', + meta: { + width: '10%', + }, + }, + { + id: 'licenses', + header: t('Licenses'), + cell: ({ row }) => ( + + {row.original.licenseIds && row.original.licenseIds.length > 0 + ? row.original.licenseIds.join(', ') + : '-'} + + ), + meta: { + width: '15%', + }, + }, + { + id: 'releases', + header: t('Releases'), + cell: ({ row }) => ( + + {row.original.releases && row.original.releases.length > 0 + ? row.original.releases.map((r) => `${r.name} ${r.version}`).join(', ') + : '-'} + + ), + meta: { + width: '15%', + }, + }, + { + id: 'comment', + header: t('Comment'), + accessorKey: 'comment', + meta: { + width: '20%', + }, + }, + ], + [ + t, + ], + ) + + const table = useReactTable({ + data: aggregatedObligations, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + meta: { + rowHeightConstant: true, + }, + }) + + useEffect(() => { + if (session.status !== 'authenticated') return + const controller = new AbortController() + const signal = controller.signal + + void (async () => { + setShowProcessing(true) + try { + // 1. Fetch current project to get its name/version + const rootProjectResponse = await ApiUtils.GET( + `projects/${projectId}`, + session.data.user.access_token, + signal, + ) + if (rootProjectResponse.status !== StatusCodes.OK) { + throw await rootProjectResponse.json() + } + const rootProject = (await rootProjectResponse.json()) as Project + + // 2. Fetch all linked projects (transitive) to get the full tree structure + const linkedProjectsResponse = await ApiUtils.GET( + `projects/${projectId}/linkedProjects?transitive=true`, + session.data.user.access_token, + signal, + ) + if (linkedProjectsResponse.status !== StatusCodes.OK) { + throw await linkedProjectsResponse.json() + } + const linkedProjectsData = (await linkedProjectsResponse.json()) as LinkedProjects + + // Extract all projects recursively from the tree structure, building projectPath as we go + const allProjectsFlat: ProjectPathInfo[] = [] + const rootVersion = rootProject.version ? ` (${rootProject.version})` : '' + extractAllProjects( + [ + rootProject, + ], + [], + allProjectsFlat, + ) + const linkedProjects = linkedProjectsData._embedded?.['sw360:projects'] ?? [] + extractAllProjects( + linkedProjects, + [ + rootProject.name + rootVersion, + ], + allProjectsFlat, + ) + + // 3. Fetch obligations for each project + const allAggregated: AggregatedObligation[] = [] + + // Map to store project obligations for "parent check" if needed + const projectObligationsMap: Record> = {} + + await Promise.all( + allProjectsFlat.map(async ({ id }) => { + if (!id) return + const endpoint = `projects/${id}/licenseObligations` + projectObligationsMap[id] = {} + try { + const res = await ApiUtils.GET(endpoint, session.data.user.access_token, signal) + if (res.status === StatusCodes.OK) { + const data = (await res.json()) as ObligationResponse + if (data.obligations) { + Object.entries(data.obligations).forEach(([title, detail]) => { + projectObligationsMap[id][title] = { + ...detail, + } + }) + } + } else { + throw new Error(`Failed to fetch obligations for project ${id}`) + } + } catch (e) { + ApiUtils.reportError(e) + } + }), + ) + + // 4. Filter and build final list + const fulfilledStatuses = [ + 'ACKNOWLEDGED_OR_FULFILLED', + 'FULFILLED_AND_PARENT_MUST_ALSO_FULFILL', + ] + + for (const { id, projectPath } of allProjectsFlat) { + const obs = projectObligationsMap[id] || {} + Object.entries(obs).forEach(([title, detail]) => { + if (detail.status && fulfilledStatuses.includes(detail.status)) { + let parentFulfillmentOk = true + if (detail.status === 'FULFILLED_AND_PARENT_MUST_ALSO_FULFILL') { + const rootObs = projectObligationsMap[projectId] || {} + const rootOb = rootObs[title] + if (!rootOb || !fulfilledStatuses.includes(rootOb.status || '')) { + parentFulfillmentOk = false + } + } + if (parentFulfillmentOk) { + allAggregated.push({ + ...detail, + projectId: id, + obligationTitle: title, + projectPath, + }) + } + } + }) + } + + setAggregatedObligations(allAggregated) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return + ApiUtils.reportError(error) + } finally { + setShowProcessing(false) + } + })() + + return () => controller.abort() + }, [ + projectId, + session, + ]) + + return ( +
+ {showProcessing ? ( +
+ +
+ ) : ( + <> + + + + + )} +
+ ) +} diff --git a/src/app/[locale]/projects/components/Obligations/Obligations.tsx b/src/app/[locale]/projects/components/Obligations/Obligations.tsx index 08d75c18f..e19897b51 100644 --- a/src/app/[locale]/projects/components/Obligations/Obligations.tsx +++ b/src/app/[locale]/projects/components/Obligations/Obligations.tsx @@ -16,6 +16,7 @@ import { Dispatch, type JSX, SetStateAction, useEffect, useState } from 'react' import { Dropdown, Nav, Tab } from 'react-bootstrap' import { AccessControl } from '@/components/AccessControl/AccessControl' import { ActionType, ObligationEntry, UserGroupType } from '@/object-types' +import AllObligationsView from './AllObligationsView' import ObligationView from './ObligationsView/ObligationsView' import ReleaseView from './ReleaseView' @@ -69,6 +70,11 @@ function Obligations({ projectId, actionType, payload, setPayload }: Props): JSX {t('Release View')} + + + {t('All Obligations')} + + {actionType === ActionType.DETAIL && ( @@ -97,6 +103,9 @@ function Obligations({ projectId, actionType, payload, setPayload }: Props): JSX + + {actionType === ActionType.DETAIL && } +