diff --git a/app/[domain]/[lang]/[plan]/(with-layout-elements)/indicators/page.tsx b/app/[domain]/[lang]/[plan]/(with-layout-elements)/indicators/page.tsx index 82b12cc0..651c9e52 100644 --- a/app/[domain]/[lang]/[plan]/(with-layout-elements)/indicators/page.tsx +++ b/app/[domain]/[lang]/[plan]/(with-layout-elements)/indicators/page.tsx @@ -29,6 +29,7 @@ export default async function ActionsPage({ params }: Props) { leadContent={data.planPage.leadContent} displayInsights={data.planPage.displayInsights} displayLevel={data.planPage.displayLevel} + includeRelatedPlans={data.planPage?.includeRelatedPlans} /> ); } diff --git a/common/__generated__/graphql.ts b/common/__generated__/graphql.ts index 10dbb79e..534e8d9c 100644 --- a/common/__generated__/graphql.ts +++ b/common/__generated__/graphql.ts @@ -2615,6 +2615,7 @@ export type IndicatorListPage = PageInterface & { goLiveAt?: Maybe; hasUnpublishedChanges: Scalars['Boolean']; id?: Maybe; + includeRelatedPlans?: Maybe; lastPublishedAt?: Maybe; latestRevision?: Maybe; latestRevisionCreatedAt?: Maybe; @@ -3715,6 +3716,7 @@ export type Query = { planPage?: Maybe; plansForHostname?: Maybe>>; relatedPlanActions?: Maybe>; + relatedPlanIndicators?: Maybe>; search?: Maybe; workflowStates?: Maybe>>; }; @@ -3810,6 +3812,14 @@ export type QueryRelatedPlanActionsArgs = { }; +export type QueryRelatedPlanIndicatorsArgs = { + category?: InputMaybe; + first?: InputMaybe; + orderBy?: InputMaybe; + plan: Scalars['ID']; +}; + + export type QuerySearchArgs = { autocomplete?: InputMaybe; includeRelatedPlans?: InputMaybe; @@ -5660,6 +5670,7 @@ export type IndicatorHightlightListQuery = ( export type IndicatorListQueryVariables = Exact<{ plan: Scalars['ID']; + relatedPlanIndicators: Scalars['Boolean']; }>; @@ -5715,6 +5726,12 @@ export type IndicatorListQuery = ( { id: string, identifier: string, order: number, name: string, parent?: ( { id: string } & { __typename?: 'Category' } + ) | null, common?: ( + { type: ( + { identifier: string, name: string } + & { __typename?: 'CommonCategoryType' } + ) } + & { __typename?: 'CommonCategory' } ) | null } & { __typename?: 'Category' } )> } @@ -5745,7 +5762,52 @@ export type IndicatorListQuery = ( & { __typename?: 'CommonIndicator' } ) | null } & { __typename?: 'Indicator' } - ) | null> | null } + ) | null> | null, relatedPlanIndicators?: Array<( + { id: string, name: string, level?: string | null, timeResolution: IndicatorTimeResolution, organization: ( + { id: string, name: string } + & { __typename?: 'Organization' } + ), common?: ( + { id: string, name: string, normalizations?: Array<( + { unit?: ( + { shortName?: string | null } + & { __typename?: 'Unit' } + ) | null, normalizer?: ( + { name: string, id: string, identifier?: string | null } + & { __typename?: 'CommonIndicator' } + ) | null } + & { __typename?: 'CommonIndicatorNormalization' } + ) | null> | null } + & { __typename?: 'CommonIndicator' } + ) | null, latestGraph?: ( + { id: string } + & { __typename?: 'IndicatorGraph' } + ) | null, latestValue?: ( + { id: string, date?: string | null, value: number, normalizedValues?: Array<( + { normalizerId?: string | null, value?: number | null } + & { __typename?: 'NormalizedValue' } + ) | null> | null } + & { __typename?: 'IndicatorValue' } + ) | null, unit: ( + { shortName?: string | null } + & { __typename?: 'Unit' } + ), categories: Array<( + { id: string, name: string, parent?: ( + { id: string } + & { __typename?: 'Category' } + ) | null, type: ( + { id: string, identifier: string, name: string } + & { __typename?: 'CategoryType' } + ), common?: ( + { id: string, identifier: string, name: string, order: number, type: ( + { identifier: string, name: string } + & { __typename?: 'CommonCategoryType' } + ) } + & { __typename?: 'CommonCategory' } + ) | null } + & { __typename?: 'Category' } + )> } + & { __typename?: 'Indicator' } + )> | null } & { __typename?: 'Query' } ); @@ -12165,7 +12227,7 @@ export type GetPlanPageIndicatorListQuery = ( { id?: string | null, slug: string, title: string, lastPublishedAt?: any | null } & { __typename: 'AccessibilityStatementPage' | 'ActionListPage' | 'CategoryPage' | 'CategoryTypePage' | 'EmptyPage' | 'ImpactGroupPage' | 'Page' | 'PlanRootPage' | 'PrivacyPolicyPage' | 'StaticPage' } ) | ( - { leadContent?: string | null, displayInsights?: boolean | null, displayLevel?: boolean | null, id?: string | null, slug: string, title: string, lastPublishedAt?: any | null } + { leadContent?: string | null, displayInsights?: boolean | null, displayLevel?: boolean | null, includeRelatedPlans?: boolean | null, id?: string | null, slug: string, title: string, lastPublishedAt?: any | null } & { __typename: 'IndicatorListPage' } ) | null } & { __typename?: 'Query' } diff --git a/components/indicators/IndicatorList.tsx b/components/indicators/IndicatorList.tsx index 17e6529f..04a0537c 100644 --- a/components/indicators/IndicatorList.tsx +++ b/components/indicators/IndicatorList.tsx @@ -1,14 +1,11 @@ 'use client'; import React, { useState } from 'react'; - import { gql } from '@apollo/client'; import { Container } from 'reactstrap'; - import ContentLoader from 'components/common/ContentLoader'; import { usePlan } from '../../context/plan'; import ErrorMessage from 'components/common/ErrorMessage'; - import IndicatorsHero from './IndicatorsHero'; import IndicatorListFiltered from './IndicatorListFiltered'; import ActionListFilters, { @@ -17,6 +14,9 @@ import ActionListFilters, { } from 'components/actions/ActionListFilters'; import { Category, + CategoryType, + CommonCategory, + CommonCategoryType, Indicator, IndicatorListPage, IndicatorListQuery, @@ -36,31 +36,104 @@ const createFilterUnusedCategories = ) ); -const getFilterConfig = (categoryType, indicators) => ({ +const createFilterUnusedCommonCategories = + (indicators: Indicator[]) => (commonCategory: CommonCategory) => + indicators.find(({ categories }) => + categories.find( + (category) => + category.common && category.common.id === commonCategory.id + ) + ); + +interface CommonCategoryGroup { + categories: Set; + type: CommonCategoryType; +} + +interface CollectedCommonCategory { + typeIdentifier: string; + categories: CommonCategory[]; + type: CommonCategoryType; +} + +const collectCommonCategories = ( + indicators: Indicator[] +): CollectedCommonCategory[] => { + const commonCategories: Record = {}; + + indicators.forEach((indicator: Indicator) => { + indicator.categories.forEach((category: Category) => { + if (category.common) { + const typeIdentifier: string = category.common.type.identifier; + if (!commonCategories[typeIdentifier]) { + commonCategories[typeIdentifier] = { + categories: new Set(), + type: { ...category.common.type }, + }; + } + commonCategories[typeIdentifier].categories.add(category.common); + } + }); + }); + + return Object.entries(commonCategories).map( + ([typeIdentifier, data]): CollectedCommonCategory => ({ + typeIdentifier, + categories: Array.from(data?.categories), + type: data?.type, + }) + ); +}; + +const getFilterConfig = ( + categoryType: CategoryType, + indicators: Indicator[], + commonCategories: CollectedCommonCategory[] | null +) => ({ mainFilters: [ - { - __typename: 'CategoryTypeFilterBlock', - field: 'category', - id: '817256d7-a6fb-4af1-bbba-096171eb0d36', - style: 'dropdown', - showAllLabel: '', - depth: null, - categoryType: { - ...categoryType, - categories: categoryType.categories.filter( - createFilterUnusedCategories(indicators) - ), - hideCategoryIdentifiers: true, - selectionType: 'SINGLE', - }, - }, + ...(commonCategories + ? commonCategories.map(({ typeIdentifier, categories, type }) => ({ + __typename: 'CategoryTypeFilterBlock', + field: 'category', + id: `common_${typeIdentifier}`, + style: 'dropdown', + showAllLabel: '', + depth: null, + categoryType: { + ...type, + name: type.name || typeIdentifier, + categories: categories.filter( + createFilterUnusedCommonCategories(indicators) + ), + hideCategoryIdentifiers: true, + selectionType: 'SINGLE', + }, + })) + : [ + { + __typename: 'CategoryTypeFilterBlock', + field: 'category', + id: '817256d7-a6fb-4af1-bbba-096171eb0d36', + style: 'dropdown', + showAllLabel: '', + depth: null, + categoryType: { + ...categoryType, + categories: categoryType.categories.filter( + createFilterUnusedCategories(indicators) + ), + hideCategoryIdentifiers: true, + selectionType: 'SINGLE', + }, + }, + ]), ], primaryFilters: [], advancedFilters: [], }); const GET_INDICATOR_LIST = gql` - query IndicatorList($plan: ID!) { + query IndicatorList($plan: ID!, $relatedPlanIndicators: Boolean!) { plan(id: $plan) { id features { @@ -130,11 +203,17 @@ const GET_INDICATOR_LIST = gql` parent { id } + common @include(if: $relatedPlanIndicators) { + type { + identifier + name + } + } } } hasIndicatorRelationships } - planIndicators(plan: $plan) { + planIndicators(plan: $plan) @skip(if: $relatedPlanIndicators) { id common { id @@ -162,6 +241,67 @@ const GET_INDICATOR_LIST = gql` } } } + relatedPlanIndicators(plan: $plan) @include(if: $relatedPlanIndicators) { + id + name + level(plan: $plan) + timeResolution + organization { + id + name + } + common { + id + name + normalizations { + unit { + shortName + } + normalizer { + name + id + identifier + } + } + } + latestGraph { + id + } + latestValue { + id + date + value + normalizedValues { + normalizerId + value + } + } + unit { + shortName + } + categories { + id + name + parent { + id + } + type { + id + identifier + name + } + common { + id + identifier + name + order + type { + identifier + name + } + } + } + } } `; @@ -171,8 +311,9 @@ interface Filters { } const filterIndicators = ( - indicators, + indicators: Indicator[], filters: Filters, + includeRelatedPlans: boolean, categoryIdentifier?: string ) => { const filterByCategory = (indicator) => @@ -182,20 +323,44 @@ const filterIndicators = ( ({ type, id }) => filters[getCategoryString(type.identifier)] === id ); + const filterByCommonCategory = (indicator) => { + const activeFilters = Object.entries(filters).filter( + ([key, value]) => value !== undefined && value !== null + ); + + if (activeFilters.length === 0) { + return true; + } + + return activeFilters.every(([filterKey, filterValue]) => { + return indicator.categories.some((category) => { + if (category.common) { + return category.common.id === filterValue; + } + return false; + }); + }); + }; + const filterBySearch = (indicator) => !filters['name'] || indicator.name.toLowerCase().includes(filters['name'].toLowerCase()); - return indicators.filter( - (indicator) => filterByCategory(indicator) && filterBySearch(indicator) - ); + return indicators.filter((indicator) => { + const categoryResult = filterByCategory(indicator); + const commonCategoryResult = filterByCommonCategory(indicator); + const searchResult = filterBySearch(indicator); + return ( + (!includeRelatedPlans ? categoryResult : commonCategoryResult) && + searchResult + ); + }); }; -/** - * IndicatorListFiltered currently only accepts and displays a single category type, - * so we use the first usableForIndicators category type which has associated indicators. - */ -const getFirstUsableCategoryType = (categoryTypes, indicators) => +const getFirstUsableCategoryType = ( + categoryTypes: CategoryType[], + indicators: Indicator[] +): CategoryType | undefined => categoryTypes.find((categoryType) => indicators.find((indicator) => indicator.categories.find(({ type }) => type.id === categoryType.id) @@ -206,12 +371,14 @@ interface Props { leadContent: IndicatorListPage['leadContent']; displayInsights: IndicatorListPage['displayInsights']; displayLevel: IndicatorListPage['displayLevel']; + includeRelatedPlans: IndicatorListPage['includeRelatedPlans']; } const IndicatorList = ({ leadContent, displayInsights, displayLevel, + includeRelatedPlans, }: Props) => { const plan = usePlan(); const t = useTranslations(); @@ -220,7 +387,10 @@ const IndicatorList = ({ const { loading, error, data } = useQuery( GET_INDICATOR_LIST, { - variables: { plan: plan.identifier }, + variables: { + plan: plan.identifier, + relatedPlanIndicators: includeRelatedPlans, + }, } ); @@ -235,21 +405,18 @@ const IndicatorList = ({ const handleFilterChange = (id: string, val: FilterValue) => { setFilters((state) => { const newFilters = { ...state, [id]: val }; - updateSearchParams(newFilters); - return newFilters; }); }; const hasInsights = (data) => { const { plan } = data; - // Check if any of the indicators has causality link return plan.hasIndicatorRelationships === true; }; const getIndicatorListProps = (data) => { - const { plan } = data; + const { plan, relatedPlanIndicators } = data; const displayMunicipality = plan.features.hasActionPrimaryOrgs === true; const displayNormalizedValues = undefined !== @@ -263,7 +430,6 @@ const IndicatorList = ({ const indicators = indicatorLevels.map((il) => { const { indicator, level } = il; - return { ...indicator, level: level.toLowerCase() }; }); @@ -272,6 +438,7 @@ const IndicatorList = ({ leadContent: generalContent.indicatorListLeadContent, displayMunicipality, displayNormalizedValues, + relatedPlanIndicators: includeRelatedPlans ? relatedPlanIndicators : [], }; }; @@ -279,16 +446,30 @@ const IndicatorList = ({ if (error) return ; const indicatorListProps = getIndicatorListProps(data); - const hierarchy = processCommonIndicatorHierarchy(data?.planIndicators); + + const indicators = includeRelatedPlans + ? indicatorListProps.relatedPlanIndicators + : indicatorListProps.indicators; + + const commonCategories = collectCommonCategories(indicators); + const hierarchy = processCommonIndicatorHierarchy( + includeRelatedPlans + ? indicatorListProps.relatedPlanIndicators + : data?.planIndicators + ); const showInsights = (displayInsights ?? true) && hasInsights(data); const categoryType = getFirstUsableCategoryType( data?.plan?.categoryTypes, - indicatorListProps.indicators + indicators ); const filterConfig = categoryType - ? getFilterConfig(categoryType, indicatorListProps.indicators) + ? getFilterConfig( + categoryType, + indicators, + includeRelatedPlans ? commonCategories : null + ) : {}; const filterSections: ActionListFilterSection[] = @@ -302,8 +483,9 @@ const IndicatorList = ({ }); const filteredIndicators = filterIndicators( - indicatorListProps.indicators, + indicators, filters, + includeRelatedPlans, categoryType?.identifier ); @@ -333,6 +515,8 @@ const IndicatorList = ({ category.type.id === categoryType?.id } displayLevel={displayLevel} + includePlanRelatedIndicators={includeRelatedPlans} + commonCategories={commonCategories} /> diff --git a/components/indicators/IndicatorListFiltered.tsx b/components/indicators/IndicatorListFiltered.tsx index 64da464f..09683926 100644 --- a/components/indicators/IndicatorListFiltered.tsx +++ b/components/indicators/IndicatorListFiltered.tsx @@ -403,6 +403,8 @@ const IndicatorListFiltered = (props) => { displayNormalizedValues, shouldDisplayCategory, displayLevel, + includePlanRelatedIndicators, + commonCategories, } = props; const locale = useLocale(); @@ -558,7 +560,17 @@ const IndicatorListFiltered = (props) => { ); } - if (someIndicatorsHaveCategories) { + if (includePlanRelatedIndicators) { + // Use common categories for columns + commonCategories.forEach((category) => { + headers.push( + + {category.type.name} + + ); + }); + } else if (someIndicatorsHaveCategories) { + // Existing code for regular categories headers.push( {categoryColumnLabel || t('themes')} @@ -583,6 +595,40 @@ const IndicatorListFiltered = (props) => { ); } + const CommonCategoriesCell = ({ item, commonCategories }) => ( + <> + {commonCategories.map((commonCategory) => ( + + {item.categories + .filter( + (cat) => + cat.common && + cat.common.type.identifier === + commonCategory.typeIdentifier + ) + .map((cat) => ( + + {cat.common.name} + + ))} + + ))} + + ); + + const RegularCategoriesCell = ({ item, shouldDisplayCategory }) => ( + + {item.categories.map((cat) => { + if (cat && (shouldDisplayCategory?.(cat) ?? true)) { + return {cat.name}; + } + return null; + })} + + ); + return ( {!indicatorNameColumnEnabled && ( @@ -693,18 +739,18 @@ const IndicatorListFiltered = (props) => { )} - {someIndicatorsHaveCategories && ( - - {item.categories.map((cat) => { - if (cat && (shouldDisplayCategory?.(cat) ?? true)) - return ( - - {cat.name} - - ); - return false; - })} - + {includePlanRelatedIndicators ? ( + + ) : ( + someIndicatorsHaveCategories && ( + + ) )} {item.latestValue && ( @@ -756,6 +802,8 @@ IndicatorListFiltered.propTypes = { indicators: PropTypes.arrayOf(PropTypes.object).isRequired, shouldDisplayCategory: PropTypes.func, displayLevel: PropTypes.bool, + includePlanRelatedIndicators: PropTypes.bool, + commonCategories: PropTypes.arrayOf(PropTypes.object), }; export default IndicatorListFiltered; diff --git a/queries/get-indicator-list-page.ts b/queries/get-indicator-list-page.ts index 145e50b4..264eebf9 100644 --- a/queries/get-indicator-list-page.ts +++ b/queries/get-indicator-list-page.ts @@ -16,6 +16,7 @@ const GET_INDICATOR_LIST_PAGE = gql` leadContent displayInsights displayLevel + includeRelatedPlans } lastPublishedAt }