diff --git a/common/__generated__/graphql.ts b/common/__generated__/graphql.ts index 8caf0271d..70da9e457 100644 --- a/common/__generated__/graphql.ts +++ b/common/__generated__/graphql.ts @@ -3358,6 +3358,7 @@ export type QueryPlanIndicatorsArgs = { export type QueryPlanOrganizationsArgs = { forContactPersons?: InputMaybe; forResponsibleParties?: InputMaybe; + includeRelatedPlans?: InputMaybe; plan?: InputMaybe; withAncestors?: InputMaybe; }; @@ -6644,6 +6645,7 @@ export type GetActionListForBlockQuery = ( export type GetActionListForGraphsQueryVariables = Exact<{ plan: Scalars['ID']; + categoryId?: InputMaybe; }>; diff --git a/common/blocks.types.ts b/common/blocks.types.ts index 583ed5b45..7666124b6 100644 --- a/common/blocks.types.ts +++ b/common/blocks.types.ts @@ -1,3 +1,6 @@ +import { ColProps } from 'reactstrap'; + export interface CommonContentBlockProps { id?: string; + columnProps?: ColProps; } diff --git a/common/preprocess.ts b/common/preprocess.ts index 93b6e8221..00997e53d 100644 --- a/common/preprocess.ts +++ b/common/preprocess.ts @@ -2,9 +2,6 @@ import { cloneDeep } from 'lodash'; import { ActionListAction } from '../components/dashboard/ActionList'; import { - Action, - ActionStatus, - ActionImplementationPhase, Plan, Sentiment, ActionStatusSummary, @@ -71,7 +68,8 @@ const cleanActionStatus = (action, actionStatuses) => { const getStatusData = ( actions: ActionListAction[], actionStatusSummaries: ActionStatusSummary[], - theme: Theme + theme: Theme, + unknownLabelText: string = '' ) => { const progress: Progress = { values: [], @@ -94,7 +92,7 @@ const getStatusData = ( const statusCount = counts.get(identifier) ?? 0; if (statusCount > 0) { progress.values.push(statusCount); - progress.labels.push(label); + progress.labels.push(label || unknownLabelText); progress.colors.push(theme.graphColors[color]); if (sentiment == Sentiment.Positive) { progress.good = progress.good + statusCount; diff --git a/components/actions/CategoryMetaBar.tsx b/components/actions/CategoryMetaBar.tsx deleted file mode 100644 index 867730394..000000000 --- a/components/actions/CategoryMetaBar.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React, { useContext } from 'react'; -import PropTypes from 'prop-types'; -import { gql, useQuery } from '@apollo/client'; -import { useTranslation } from 'common/i18n'; -import styled, { useTheme } from 'styled-components'; -import PlanContext from 'context/plan'; -import { getStatusData } from 'common/preprocess'; - -export const GET_ACTION_STATUSES = gql` - query GetActionStatuses($plan: ID!, $actionCategory: ID) { - planActions(plan: $plan, category: $actionCategory) { - id - identifier - plan { - id - } - color - status { - id - identifier - name - } - statusSummary { - identifier - } - implementationPhase { - id - identifier - name - } - mergedWith { - id - identifier - plan { - id - shortName - viewUrl - } - } - } - } -`; - -const CategoryMetaStatus = styled.div` - margin-bottom: ${(props) => props.theme.spaces.s200}; - - h3 { - font-size: ${(props) => props.theme.fontSizeBase}; - } -`; - -const Status = styled.div` - color: ${(props) => props.theme.themeColors.black}; -`; - -const BarGraph = styled.div` - display: flex; - height: 1rem; - width: auto; - background-color: ${(props) => props.theme.themeColors.light}; -`; - -const Segment = styled.div` - background-color: ${(props) => props.color}; - width: ${(props) => props.portion}%; - height: 1rem; -`; - -const Labels = styled.div` - display: flex; - width: auto; -`; - -const SegmentLabel = styled.span` - display: flex; - flex-direction: column; - flex-basis: ${(props) => props.portion}%; - text-align: left; - margin: ${(props) => props.theme.spaces.s050} - ${(props) => props.theme.spaces.s050} 0 0; - font-size: ${(props) => props.theme.fontSizeSm}; - font-family: ${(props) => props.theme.fontFamilyTiny}; - line-height: ${(props) => props.theme.lineHeightMd}; - - span { - align-self: flex-start; - } - - .value { - font-weight: ${(props) => props.theme.fontWeightBold}; - } -`; - -interface Props { - category: string; -} - -function CategoryMetaBar({ category }: Props) { - const plan = useContext(PlanContext); - const { t } = useTranslation(['actions']); - const theme = useTheme(); - let statusData = {}; - let actionCount = 0; - const { loading, error, data } = useQuery(GET_ACTION_STATUSES, { - variables: { plan: plan.identifier, actionCategory: category }, - }); - - if (loading) return
; - if (error) return
; - - const { planActions } = data; - statusData = getStatusData(planActions, plan.actionStatusSummaries, theme); - actionCount = statusData.values.reduce((total, num) => total + num, 0); - - if (statusData.values.length < 1) return null; - - const segments = statusData?.labels.map((segment, indx) => ({ - id: segment, - label: statusData.labels[indx], - value: `${Math.round((statusData.values[indx] / actionCount) * 100)} %`, - portion: statusData.values[indx] / actionCount, - color: statusData.colors[indx], - })); - - return ( - -

{t('action-progress')}

-
- - - {segments.map((segment) => ( - - ))} - - - {segments.map((segment) => ( - - {segment.value} - {segment.label} - - ))} - - -
-
- ); -} - -export default CategoryMetaBar; diff --git a/components/common/BarChart.tsx b/components/common/BarChart.tsx new file mode 100644 index 000000000..b520aa736 --- /dev/null +++ b/components/common/BarChart.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { Progress } from 'components/dashboard/ActionStatusGraphs'; + +const BarChartWrapper = styled.div` + margin-bottom: ${(props) => props.theme.spaces.s200}; + + h3 { + font-size: ${(props) => props.theme.fontSizeBase}; + } +`; + +const Status = styled.div` + color: ${(props) => props.theme.themeColors.black}; +`; + +const BarGraph = styled.div` + display: flex; + height: 1rem; + width: auto; + background-color: ${(props) => props.theme.themeColors.light}; +`; + +const Segment = styled.div<{ portion: number }>` + background-color: ${(props) => props.color}; + width: ${(props) => props.portion}%; + height: 1rem; +`; + +const Labels = styled.div` + display: flex; + width: auto; +`; + +const SegmentLabel = styled.span<{ portion: number }>` + display: flex; + flex-direction: column; + flex-basis: ${(props) => props.portion}%; + text-align: left; + margin: ${(props) => props.theme.spaces.s050} + ${(props) => props.theme.spaces.s050} 0 0; + font-size: ${(props) => props.theme.fontSizeSm}; + font-family: ${(props) => props.theme.fontFamilyTiny}; + line-height: ${(props) => props.theme.lineHeightMd}; + + span { + align-self: flex-start; + } + + .value { + font-weight: ${(props) => props.theme.fontWeightBold}; + } +`; + +interface BarChartProps { + title: string; + data: Progress; +} + +interface Segment { + id: string; + label: string; + value: string; + portion: number; + color: string; +} + +function BarChart({ title, data }: BarChartProps) { + if (data.values.length < 1) { + return null; + } + + const valueSum = data.values.reduce((total, value) => total + value, 0); + const segments = data.labels + .map((label, i) => ({ + id: label, + label: data.labels[i], + value: `${Math.round((data.values[i] / valueSum) * 100)} %`, + portion: data.values[i] / valueSum, + color: data.colors[i], + })) + .filter(({ portion }) => portion > 0); + + return ( + +

{title}

+
+ + + {segments.map((segment) => ( + + ))} + + + {segments.map((segment) => ( + + {segment.value} + {segment.label} + + ))} + + +
+
+ ); +} + +export default BarChart; diff --git a/components/common/CategoryPageStreamField.tsx b/components/common/CategoryPageStreamField.tsx index b4773c32f..c1cc9b430 100644 --- a/components/common/CategoryPageStreamField.tsx +++ b/components/common/CategoryPageStreamField.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Container, Row, Col, ColProps } from 'reactstrap'; import { usePlan } from 'context/plan'; -import CategoryMetaBar from 'components/actions/CategoryMetaBar'; import { attributeHasValue } from 'components/common/AttributesBlock'; import { useTheme } from 'common/theme'; import { @@ -13,6 +12,8 @@ import ActionAttribute from 'components/common/ActionAttribute'; import CategoryListBlock from 'components/contentblocks/CategoryListBlock'; import ExpandableFeedbackFormBlock from 'components/contentblocks/ExpandableFeedbackFormBlock'; import StreamField from 'components/common/StreamField'; +import ActionStatusGraphsBlock from 'components/contentblocks/ActionStatusGraphsBlock'; +import { ChartType } from 'components/dashboard/ActionStatusGraphs'; export type CategoryPage = { __typename: 'CategoryPage' } & NonNullable< GetPlanPageGeneralQuery['planPage'] @@ -20,10 +21,7 @@ export type CategoryPage = { __typename: 'CategoryPage' } & NonNullable< type OmitUnion = T extends any ? Omit : never; -type OmitFields = OmitUnion< - T, - 'blockType' | 'field' | 'rawValue' | 'blocks' ->; +type OmitFields = OmitUnion; interface WrapperProps { children: React.ReactNode; @@ -135,10 +133,21 @@ export const CategoryPageStreamField = ({ return null; } + // The editor specifies whether to visualise action progress by implementation phase or status + const progressDataset = block.blocks[0].value || 'implementation_phase'; + return ( - + ); diff --git a/components/contentblocks/ActionStatusGraphsBlock.tsx b/components/contentblocks/ActionStatusGraphsBlock.tsx index f165842d4..6c22967ab 100644 --- a/components/contentblocks/ActionStatusGraphsBlock.tsx +++ b/components/contentblocks/ActionStatusGraphsBlock.tsx @@ -8,12 +8,14 @@ import ErrorMessage from 'components/common/ErrorMessage'; import PlanContext from 'context/plan'; import { useTheme } from 'common/theme'; import { useTranslation } from 'common/i18n'; -import ActionStatusGraphs from 'components/dashboard/ActionStatusGraphs'; +import ActionStatusGraphs, { + ActionsStatusGraphsProps, +} from 'components/dashboard/ActionStatusGraphs'; import { CommonContentBlockProps } from 'common/blocks.types'; const GET_ACTION_LIST_FOR_GRAPHS = gql` - query GetActionListForGraphs($plan: ID!) { - planActions(plan: $plan) { + query GetActionListForGraphs($plan: ID!, $categoryId: ID) { + planActions(plan: $plan, category: $categoryId) { color statusSummary { identifier @@ -29,7 +31,18 @@ const GET_ACTION_LIST_FOR_GRAPHS = gql` } `; -const ActionStatusGraphsBlock = ({ id = '' }: CommonContentBlockProps) => { +interface Props + extends CommonContentBlockProps, + Pick { + categoryId?: string; +} + +const ActionStatusGraphsBlock = ({ + id = '', + categoryId, + columnProps, + ...graphsProps +}: Props) => { const plan = useContext(PlanContext); const { t } = useTranslation(); const theme = useTheme(); @@ -41,8 +54,10 @@ const ActionStatusGraphsBlock = ({ id = '' }: CommonContentBlockProps) => { const { loading, error, data } = useQuery(GET_ACTION_LIST_FOR_GRAPHS, { variables: { plan: plan.identifier, + categoryId, }, }); + if (loading) return ; if (error) return ; const { planActions } = data; @@ -52,10 +67,19 @@ const ActionStatusGraphsBlock = ({ id = '' }: CommonContentBlockProps) => { return ( - + diff --git a/components/contentblocks/CategoryPageHeaderBlock.tsx b/components/contentblocks/CategoryPageHeaderBlock.tsx index 49446f45d..2a4f96e29 100644 --- a/components/contentblocks/CategoryPageHeaderBlock.tsx +++ b/components/contentblocks/CategoryPageHeaderBlock.tsx @@ -5,7 +5,6 @@ import styled from 'styled-components'; import PlanContext, { usePlan } from 'context/plan'; import { Link } from 'common/links'; import { useTranslation } from 'common/i18n'; -import CategoryMetaBar from 'components/actions/CategoryMetaBar'; import AttributesBlock, { Attributes, attributeHasValue, @@ -19,6 +18,8 @@ import { import CategoryPageStreamField, { CategoryPage, } from 'components/common/CategoryPageStreamField'; +import { ChartType } from 'components/dashboard/ActionStatusGraphs'; +import ActionStatusGraphsBlock from './ActionStatusGraphsBlock'; export const GET_CATEGORY_ATTRIBUTE_TYPES = gql` query GetCategoryAttributeTypes($plan: ID!) { @@ -224,7 +225,12 @@ const LegacyCategoryHeaderAttributes = ({ {plan.actionStatuses.length ? ( - + ) : null} ) : null; diff --git a/components/dashboard/ActionList.tsx b/components/dashboard/ActionList.tsx index 7638a3ef1..e23be9ba5 100644 --- a/components/dashboard/ActionList.tsx +++ b/components/dashboard/ActionList.tsx @@ -618,7 +618,11 @@ const ActionList = (props: ActionListProps) => { <> { - const { actions, showUpdateStatus = true } = props; +const ActionsStatusGraphs = ({ + actions, + chart = ChartType.DONUT, + shownDatasets = DEFAULT_DATASETS, +}: ActionsStatusGraphsProps) => { const theme = useTheme(); const plan = useContext(PlanContext); const { t } = useTranslation(['common']); - const progressData = getStatusData( - actions, - plan.actionStatusSummaries, - theme - ); - progressData.labels = progressData.labels.map( - (label) => label || t('unknown') - ); - const timelinessData = getTimelinessData(actions, plan, theme); + const progressData = + shownDatasets.progress && + getStatusData(actions, plan.actionStatusSummaries, theme, t('unknown')); + + const timelinessData = + shownDatasets.timeliness && getTimelinessData(actions, plan, theme); + const daysVisible = plan.actionTimelinessClasses.find( (c) => c.identifier === ActionTimelinessIdentifier.Acceptable )!.days; - let phaseData: Progress | undefined = undefined; - if (plan.actionImplementationPhases.length > 0) { - phaseData = getPhaseData(actions, plan, theme, t); + + const phaseData = + shownDatasets.phase && plan.actionImplementationPhases.length > 0 + ? getPhaseData(actions, plan, theme, t) + : undefined; + + if (chart === ChartType.BAR) { + return ( + <> + {progressData && ( + + )} + + {phaseData && } + + ); } return ( - + {phaseData && ( { helpText={t('actions-phases-help')} /> )} - {!plan.features.minimalStatuses && ( + + {!plan.features.minimalStatuses && progressData && ( { helpText={t('actions-status-help')} /> )} - {showUpdateStatus && ( + + {timelinessData && ( { helpText={t('actions-updated-help', { count: daysVisible })} /> )} - + ); }; -ActionsStatusGraphs.propTypes = { - actions: PropTypes.arrayOf(PropTypes.object).isRequired, -}; - export default ActionsStatusGraphs;