diff --git a/js/api/eclipsefdn.interest-groups.js b/js/api/eclipsefdn.interest-groups.js index a65b147f697943fd76b44cf93be38fb15c3ba7c5..74bc2c4b938ae10688234e96066a7e37af031868 100644 --- a/js/api/eclipsefdn.interest-groups.js +++ b/js/api/eclipsefdn.interest-groups.js @@ -98,3 +98,27 @@ export const getInterestGroupParticipatingOrganizations = async (options) => { return [null, error]; } }; + +/** + * Retrieves interest groups that are associated with an organization. + * + * @param organizationId - The ID of the organization + * @returns Interest groups + */ +export const getOrganizationInterestGroups = async (organizationId) => { + try { + const [interestGroups, error] = await getInterestGroups(); + if (error) throw error; + + // Filter interest groups who include any participant or lead that are part + // of the organization. + const organizationInterestGroups = interestGroups + .filter((interestGroup) => [...interestGroup.leads, ...interestGroup.participants] + .some((person) => parseInt(person.organization.id) === organizationId) + ); + + return [organizationInterestGroups, null]; + } catch (error) { + return [null, error]; + } +}; diff --git a/js/api/eclipsefdn.working-groups.ts b/js/api/eclipsefdn.working-groups.ts index d15c35ad9f9510aa8ccf73b4510c2c063da3773f..3a24d351c0e61900d932cd54ecd5cd6be7c10313 100644 --- a/js/api/eclipsefdn.working-groups.ts +++ b/js/api/eclipsefdn.working-groups.ts @@ -10,6 +10,8 @@ import 'isomorphic-fetch'; import { transformSnakeToCamel } from './utils'; +import { WorkingGroup } from '../types/models'; +import { APIResponse, HttpError } from '../types/api'; const apiBasePath = 'https://api.eclipse.org/working-groups'; @@ -26,3 +28,27 @@ export const getWorkingGroups = async () => { } } +/** + * Retrieves working groups that are associated with an organization. + * + * @param organizationId - The ID of the organization + * @returns Working groups + */ +export const getOrganizationWorkingGroups = async (organizationId: number): Promise<APIResponse<WorkingGroup[]>> => { + try { + if (!organizationId) { + throw new TypeError('No organization id was provided for fetching organization working groups'); + } + + const response = await fetch(`${apiBasePath}/organization/${organizationId}`); + if (!response.ok) throw new HttpError(response.status, `could not fetch working groups for organization ${organizationId}`); + + const data = await response.json(); + const workingGroups = data.map(transformSnakeToCamel) as WorkingGroup[]; + + return [workingGroups, null]; + } catch (error) { + return [null, error]; + } +} + diff --git a/js/shared/utils/index.ts b/js/shared/utils/index.ts index 6f2a8ac617d2e3bf81ed257ef5140e4de5ba1722..4d523fa92cbf27486dddbd39f25a8f28201299b7 100644 --- a/js/shared/utils/index.ts +++ b/js/shared/utils/index.ts @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { WorkingGroup, InterestGroup, Project, ProjectProposal } from '../../types/models'; +import { IndustryCollaboration, WorkingGroup, InterestGroup, Project, ProjectProposal } from '../../types/models'; /** * Converts a string to a boolean. @@ -198,3 +198,27 @@ export const isProjectProposal = (value: object): value is ProjectProposal => { ); }; + +// Strings + +/** + * Removes non-word characters such as whitespace. + * + * @param str - the string to remove non-word characters from + * @returns the same string without non-word characters + */ +export const removeNonWordChars = (str: string) => str.replaceAll(/\W/gi, ''); + +// Comparators + +/** + * A comparator to sort working groups and interest groups alphabetically by + * title. + * + * @param a - an industry collaboration + * @param b - another industry collaboration to compare against + */ +export const industryCollaborationComparator = (a: IndustryCollaboration, b: IndustryCollaboration) => { + return removeNonWordChars(a.title).localeCompare(removeNonWordChars(b.title)) +}; + diff --git a/js/solstice/eclipsefdn.members-detail.js b/js/solstice/eclipsefdn.members-detail.js index 34a8da6eeb2e42d892bfbc1a8e0668a67ea99737..6f4a36e7523b2e7310590b8cb022f32d240189c4 100644 --- a/js/solstice/eclipsefdn.members-detail.js +++ b/js/solstice/eclipsefdn.members-detail.js @@ -12,10 +12,12 @@ */ import { getOrganizationById, getOrganizationProducts, getOrganizationProjects } from '../api/eclipsefdn.membership'; +import { getOrganizationWorkingGroups } from '../api/eclipsefdn.working-groups'; +import { getOrganizationInterestGroups } from '../api/eclipsefdn.interest-groups'; import template from './templates/explore-members/member-detail.mustache'; import { SD_IMG, AP_IMG, AS_IMG } from './eclipsefdn.constants.js'; import { validateURL } from '../api/utils'; -import { getMonthName } from '../shared/utils'; +import { industryCollaborationComparator, getMonthName } from '../shared/utils'; const render = async () => { const element = document.querySelector('.member-detail'); @@ -92,12 +94,16 @@ const render = async () => { getOrganizationById(orgId), getOrganizationProjects(orgId), getOrganizationProducts(orgId), + getOrganizationWorkingGroups(orgId), + getOrganizationInterestGroups(orgId), ]; let responses = await Promise.all(pool); const [organization, orgErr] = responses[0]; const [projects, projectErr] = responses[1]; const [products, productsErr] = responses[2]; + const [workingGroups] = responses[3]; + const [interestGroups] = responses[4]; // Execute the err handler if an error occurred during any request. if (orgErr || projectErr || productsErr) { @@ -109,7 +115,9 @@ const render = async () => { const memberDetailData = { ...organization, projects, - products + products, + workingGroups: workingGroups.sort(industryCollaborationComparator), + interestGroups: interestGroups.sort(industryCollaborationComparator), }; const getMemberLevelImg = function () { diff --git a/js/solstice/eclipsefdn.weighted-collaborations.ts b/js/solstice/eclipsefdn.weighted-collaborations.ts index 67a36562fc79b1b4be554433ed62791fdeb0d944..b5f1147efbdff996384e93e98b172490e7e36362 100644 --- a/js/solstice/eclipsefdn.weighted-collaborations.ts +++ b/js/solstice/eclipsefdn.weighted-collaborations.ts @@ -15,7 +15,7 @@ import { getWorkingGroups } from '../api/eclipsefdn.working-groups'; //@ts-ignore Types do not exist yet import { getInterestGroups } from '../api/eclipsefdn.interest-groups'; import { stringToBoolean, isWorkingGroup, getCollaborationType, repeatSelectUnique } from '../shared/utils'; -import { InterestGroup, WorkingGroup } from '../types/models'; +import { InterestGroup, WorkingGroup, IndustryCollaboration } from '../types/models'; interface Options { templateId: string; @@ -136,9 +136,9 @@ const render = async (): Promise<void> => { export default { render }; /** Resolves the website for a given industry collaboration */ -const resolveCollaborationWebsite = (collaboration: WorkingGroup | InterestGroup): string => { - if (collaboration.resources.website) { - return collaboration.resources.website; +const resolveCollaborationWebsite = (collaboration: IndustryCollaboration): string => { + if (isWorkingGroup(collaboration) && collaboration.resources.website) { + return collaboration?.resources.website; } // Use the explore page as a fallback when the collaboration has no @@ -147,9 +147,9 @@ const resolveCollaborationWebsite = (collaboration: WorkingGroup | InterestGroup switch (getCollaborationType(collaboration)) { case 'working-group': - return `${exploreUrl}#wg-${collaboration.alias}`; + return `${exploreUrl}#wg-${(collaboration as WorkingGroup).alias}`; case 'interest-group': - return collaboration.shortProjectId ? `${exploreUrl}#${collaboration.shortProjectId}` : exploreUrl; + return (collaboration as InterestGroup).shortProjectId ? `${exploreUrl}#${(collaboration as InterestGroup).shortProjectId}` : exploreUrl; default: throw new TypeError('Could not resolve website for the given industry collaboration type.'); } @@ -203,7 +203,7 @@ const collaborationWeights: Record<string, number> = { * * @returns The map of weights and buckets with the added collaboration. */ -const assignCollaborationToBucket = (weights: Map<number, WorkingGroup | InterestGroup>, collaboration: WorkingGroup | InterestGroup) => { +const assignCollaborationToBucket = (weights: Map<number, IndustryCollaboration[]>, collaboration: IndustryCollaboration) => { // Only working groups have the `alias`. let weight: number = isWorkingGroup(collaboration) && collaborationWeights[collaboration.alias]; @@ -230,7 +230,7 @@ const assignCollaborationToBucket = (weights: Map<number, WorkingGroup | Interes * * @returns An industry collaboration. */ -const pickRandomCollaborationFromWeights = (buckets: Map<number, WorkingGroup | InterestGroup>): Array<WorkingGroup | InterestGroup> => { +const pickRandomCollaborationFromWeights = (buckets: Map<number, IndustryCollaboration[]>): IndustryCollaboration => { const highestWeight = Array.from(buckets.keys()) .sort() .at(-1); diff --git a/js/solstice/eclipsefdn.wgs-list.js b/js/solstice/eclipsefdn.wgs-list.js index 6d149ad65b5a7fd38fca2c0414d09fcaee8a8f32..ab8f1996a30105b052687764bd692c86b76797be 100644 --- a/js/solstice/eclipsefdn.wgs-list.js +++ b/js/solstice/eclipsefdn.wgs-list.js @@ -14,6 +14,7 @@ */ import { displayErrMsg } from './utils'; +import { industryCollaborationComparator } from '../shared/utils'; import template from './templates/explore-working-groups/working-group-list.mustache'; import templateLoading from './templates/loading-icon.mustache'; @@ -34,8 +35,7 @@ const render = async () => { wgsData = wgsData.filter((workingGroup) => workingGroup?.parent_organization !== 'ohwg'); // Remove any non-word characters from title while sorting - const removeNonWordChars = wgTitle => wgTitle.replaceAll(/\W/gi, ''); - wgsData = wgsData.sort((a, b) => removeNonWordChars(a.title).localeCompare(removeNonWordChars(b.title))); + wgsData = wgsData.sort(industryCollaborationComparator); } catch (error) { displayErrMsg(element, error); diff --git a/js/solstice/templates/explore-members/member-detail.mustache b/js/solstice/templates/explore-members/member-detail.mustache index 604375b6d7de8c5608ae66198035cee2877e02d0..73384c2add547626b33124a7cf9f2bb0e6cc9bad 100644 --- a/js/solstice/templates/explore-members/member-detail.mustache +++ b/js/solstice/templates/explore-members/member-detail.mustache @@ -16,6 +16,46 @@ <p>{{{trimDescription}}}</p> + <hr class="margin-top-60 margin-bottom-40"> + + {{#workingGroups.length}} + <h2>Working Group Membership</h2> + <ul class="member-detail-working-group-list logo-list logo-list-lg margin-top-30 margin-bottom-60"> + {{#workingGroups}} + <li> + <a class="link-unstyled flex-center h-100" {{#resources.website}}href="{{resources.website}}"{{/resources.website}}> + {{#logo}} + <img src="{{logo}}" alt="{{title}}"> + {{/logo}} + {{^logo}} + <p class="logo-placeholder-text">{{title}}</p> + {{/logo}} + </a> + </li> + {{/workingGroups}} + </ul> + {{/workingGroups.length}} + {{#interestGroups.length}} + <h2>Interest Group Membership</h2> + <ul class="member-detail-interest-group-list logo-list logo-list-lg margin-top-30 margin-bottom-60"> + {{#interestGroups}} + <li> + <a class="link-unstyled flex-center h-100" + {{#resources.website}}href="{{resources.website}}"{{/resources.website}} + {{^resources.website}}href="https://projects.eclipse.org/node/{{id}}"{{/resources.website}} + > + {{#logo}} + <img src="{{logo}}" alt="{{title}}"> + {{/logo}} + {{^logo}} + <p class="logo-placeholder-text">{{title}}</p> + {{/logo}} + </a> + </li> + {{/interestGroups}} + </ul> + {{/interestGroups.length}} + {{#listings}} <h2>{{name}}'s Marketplace Listings</h2> <ul> diff --git a/js/solstice/tests/eclipsefdn.members-detail.test.js b/js/solstice/tests/eclipsefdn.members-detail.test.js index e570da19c0ee94d11936b7fd4dd21ea289c7b4bd..23212488c741aeae63e685d910091ac2abe9c3d1 100644 --- a/js/solstice/tests/eclipsefdn.members-detail.test.js +++ b/js/solstice/tests/eclipsefdn.members-detail.test.js @@ -14,7 +14,11 @@ import eclipsefdnMembersDetail from '../eclipsefdn.members-detail'; import * as membershipAPIModule from '../../api/eclipsefdn.membership'; +import * as workingGroupsAPIModule from '../../api/eclipsefdn.working-groups'; +import * as interestGroupsAPIModule from '../../api/eclipsefdn.interest-groups'; import { mockOrganizations, mockOrganizationProducts, mockOrganizationProjects } from '../../api/tests/mocks/membership/mock-data'; +import { mockInterestGroups } from '../../api/tests/mocks/interest-groups/mock-data'; +import { wait } from '../utils'; /* * Mutates the search params from `window.location`. This mutates @@ -40,6 +44,8 @@ const mockSearchParams = (searchParams) => { let getOrganizationByIdSpy; let getOrganizationProductsSpy; let getOrganizationProjectsSpy; +let getOrganizationWorkingGroupsSpy; +let getOrganizationInterestGroupsSpy; const useOrganizationMock = (options) => { let organization; @@ -69,11 +75,24 @@ const useOrganizationMock = (options) => { model: mockOrganizationProjects.model[organization.model.organizationId], }; + const workingGroups = { + api: organization.api.wgpas.map((wgpa) => wgpa.working_group), + model: organization.model.wgpas.map((wgpa) => wgpa.workingGroup), + } + + // Mutate the interest groups to contain at least one participant from the + // organization. + let interestGroups = JSON.parse(JSON.stringify(mockInterestGroups)); // Deep copy to prevent mutating the mock data which could be used by other tests. + mockInterestGroups.api.forEach((interestGroup) => interestGroup.participants.at(0).organization.id = organization.model.organizationId); + mockInterestGroups.model.forEach((interestGroup) => interestGroup.participants.at(0).organization.id = organization.model.organizationId); + getOrganizationByIdSpy.mockImplementation(() => [organization.model, null]); getOrganizationProductsSpy.mockImplementation(() => [products.model, null]); getOrganizationProjectsSpy.mockImplementation(() => [projects.model, null]); + getOrganizationWorkingGroupsSpy.mockImplementation(() => [workingGroups.model, null]); + getOrganizationInterestGroupsSpy.mockImplementation(() => [interestGroups.model, null]); - return { organization, products, projects }; + return { organization, products, projects, interestGroups }; } describe('eclipsefdn-members-detail', () => { @@ -81,6 +100,8 @@ describe('eclipsefdn-members-detail', () => { getOrganizationByIdSpy = jest.spyOn(membershipAPIModule, 'getOrganizationById'); getOrganizationProductsSpy = jest.spyOn(membershipAPIModule, 'getOrganizationProducts'); getOrganizationProjectsSpy = jest.spyOn(membershipAPIModule, 'getOrganizationProjects'); + getOrganizationWorkingGroupsSpy = jest.spyOn(workingGroupsAPIModule, 'getOrganizationWorkingGroups'); + getOrganizationInterestGroupsSpy = jest.spyOn(interestGroupsAPIModule, 'getOrganizationInterestGroups'); }); it('Should display error message for missing member_id', async () => { @@ -155,6 +176,7 @@ describe('eclipsefdn-members-detail', () => { const params = new URLSearchParams(); params.set('member_id', organization.model.organizationId); + const cleanup = mockSearchParams(params); document.body.innerHTML = ` @@ -249,6 +271,66 @@ describe('eclipsefdn-members-detail', () => { cleanup(); }); + it('Should show working groups associated with an organization', async () => { + const { organization } = useOrganizationMock(); + + const params = new URLSearchParams(); + params.set('member_id', organization.model.organizationId); + const cleanup = mockSearchParams(params); + + document.body.innerHTML = ` + <div class="member-detail"></div> + `; + + await eclipsefdnMembersDetail.render(); + + // Create a set of working groups that have been rendered on the page. + const workingGroups = new Set(Array + .from(document.querySelectorAll('.member-detail-working-group-list img, .member-detail-working-group-list .logo-placeholder-text')) + // The alt text would include the working group name. + .map((element) => element.getAttribute('alt') ?? element.innerHTML)); + + expect(workingGroups.size).not.toBe(0); + // Expect every working group within the mock organization to be found in + // the set of rendered working groups. + expect( + organization.api.wgpas + .map((wgpa) => wgpa.working_group) + .every((workingGroup) => workingGroups.has(workingGroup)) + ) + + cleanup(); + }); + + it('Should show interest groups associated with an organization', async () => { + const { organization, interestGroups } = useOrganizationMock(); + + const params = new URLSearchParams(); + params.set('member_id', organization.model.organizationId); + const cleanup = mockSearchParams(params); + + document.body.innerHTML = ` + <div class="member-detail"></div> + `; + + await eclipsefdnMembersDetail.render(); + + // Create a set of working groups that have been rendered on the page. + const renderedInterestGroups = new Set(Array + .from(document.querySelectorAll('.member-detail-interest-group-list img, .member-detail-interest-group-list .logo-placeholder-text')) + // The alt text would include the working group name. + .map((element) => element.getAttribute('alt') ?? element.innerHTML)); + + expect(renderedInterestGroups.size).not.toBe(0); + // Expect every interest group within the mock organization to be found in + // the set of rendered interest groups. + expect( + interestGroups.model.every((interestGroup) => renderedInterestGroups.has(interestGroup.title)) + ) + + cleanup(); + }); + // it('Should not show links sidebar for organization without products or projects', () => {}); // it('Should display formatted member since date', () => {}); // it('Should render long description without line breaks', () => {}); diff --git a/js/solstice/utils/index.ts b/js/solstice/utils/index.ts index 70ade3b11b5133dbcd44348255ec60cb2ba91efd..5a8a3a7c2820c7229383a98b182ba69889d5a26d 100644 --- a/js/solstice/utils/index.ts +++ b/js/solstice/utils/index.ts @@ -84,4 +84,3 @@ export const waitForRender = (element: HTMLElement | Node): Promise<void> => { observer.observe(element, { childList: true }); }); }; - diff --git a/js/types/models.ts b/js/types/models.ts index a0286287e517b28c15645015c28fb25b45154ce9..f9dadf35fc3558f9322aa6be87d369cca64cbe91 100644 --- a/js/types/models.ts +++ b/js/types/models.ts @@ -115,11 +115,48 @@ export interface OrganizationProduct { url: string; } -//== Working Group Models -export type WorkingGroup = any; +//== Industry Collaboration Models +export interface WorkingGroupRelationLevels { + relation: string; + description: string; +} +export interface WorkingGroup { + alias: string; + title: string; + status: 'active' | 'inactive' | 'proposal'; + logo?: string; + description?: string; + parentOrganization: string; + resources: { + charter: string; + website: string; + members: string; + sponsorship: string; + contactForm: string; + participationAgreements: { + individual?: unknown; + organization: { + pdf: string; + documentId: string; + } + } + }, + levels: WorkingGroupRelationLevels[]; +}; + +export interface InterestGroup { + shortProjectId: string; + title: string; + state: 'active'; + description: { + summary: string; + full: string; + }, + logo: string; +}; + +export type IndustryCollaboration = WorkingGroup | InterestGroup; -//== Project Models -export type InterestGroup = any; //== Project Models export interface PMIUser { diff --git a/less/astro/components/_lists.less b/less/astro/components/_lists.less new file mode 100644 index 0000000000000000000000000000000000000000..33780cdf4a80bba367713a71310a826778469e2c --- /dev/null +++ b/less/astro/components/_lists.less @@ -0,0 +1,43 @@ +/*! + * Copyright (c) 2025 Eclipse Foundation, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 +*/ + +.logo-list { + .list-unstyled; + display: flex; + gap: 2rem; + flex-wrap: wrap; + text-align: center; + + li { + max-width: 11rem; + height: 8rem; + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + &-lg { + gap: 4rem; + + li { + max-width: 20rem; + height: 8rem; + } + } + + .logo-placeholder-text { + font-size: @logo-placeholder-text-font-size; + font-weight: @logo-placeholder-text-font-weight; + } +} + diff --git a/less/astro/main.less b/less/astro/main.less index 4ffe547913c467a71eb8853405d6cf1c378abb2d..44599b689ba668089938883fe15521cbd87e3afc 100644 --- a/less/astro/main.less +++ b/less/astro/main.less @@ -96,6 +96,7 @@ @import 'components/_feed-list.less'; @import 'components/_timeline.less'; @import 'components/_header-nav.less'; +@import 'components/_lists.less'; @import 'components/_news-list.less'; @import 'components/_project-list.less'; @import 'components/_resources-group.less'; diff --git a/tsconfig.json b/tsconfig.json index 6171fbeac0edad5a7aabcae5d318de2880bf1cb0..4b7933149fcde01e11da24342faff20f47098147 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "noImplicitAny": true, "sourceMap": true, "allowSyntheticDefaultImports": true, + "lib": ["ES2022"], }, "include": ["js/**/*"] }