+
{
insertMarkdown(
markdown,
textareaRef.current,
- (text, caretPos) => {
- onChange(text)
- textareaRef.current.focus()
- textareaRef.current.selectionEnd = caretPos
- }
+ insertMarkdownCallback
)
if (markdown === MENTION) {
@@ -231,16 +262,18 @@ export const RichTextEditor = forwardRef(
/>
{previewMode ? (
) : (
-
+
-
+ {errorText && (
+ {errorText}
+ )}
+ {helpText && {helpText}}
+
)}
@@ -261,12 +301,20 @@ export const RichTextEditor = forwardRef(
}
)
-RichTextEditor.displayName = 'RichTextEditor'
+Editor.displayName = 'Editor'
+
+Editor.defaultProps = {
+ initialFocus: true,
+ resizable: true,
+}
-RichTextEditor.propTypes = {
+Editor.propTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
errorText: PropTypes.string,
+ helpText: PropTypes.string,
+ initialFocus: PropTypes.bool,
inputPlaceholder: PropTypes.string,
+ resizable: PropTypes.bool,
}
diff --git a/src/components/RichText/Editor/__tests__/Editor.spec.js b/src/components/RichText/Editor/__tests__/Editor.spec.js
new file mode 100644
index 000000000..97eaf217b
--- /dev/null
+++ b/src/components/RichText/Editor/__tests__/Editor.spec.js
@@ -0,0 +1,47 @@
+import '@testing-library/jest-dom'
+import { render, screen, fireEvent } from '@testing-library/react'
+import React from 'react'
+import { Editor } from '../Editor.js'
+
+const mockConvertCtrlKey = jest.fn()
+jest.mock('../markdownHandler.js', () => ({
+ convertCtrlKey: () => mockConvertCtrlKey(),
+}))
+
+jest.mock('../../../UserMention/UserMentionWrapper.js', () => ({
+ UserMentionWrapper: jest.fn((props) => <>{props.children}>),
+}))
+
+describe('RichText: Editor component', () => {
+ const componentProps = {
+ value: '',
+ onChange: jest.fn(),
+ }
+
+ beforeEach(() => {
+ mockConvertCtrlKey.mockClear()
+ })
+
+ const renderComponent = (props) => {
+ return render(
)
+ }
+
+ it('renders a result', () => {
+ renderComponent(componentProps)
+
+ expect(
+ screen.getByTestId('@dhis2-analytics-richtexteditor')
+ ).toBeVisible()
+ })
+
+ it('calls convertCtrlKey on keydown', () => {
+ renderComponent(componentProps)
+
+ fireEvent.keyDown(screen.getByRole('textbox'), {
+ key: 'A',
+ code: 'keyA',
+ })
+
+ expect(mockConvertCtrlKey).toHaveBeenCalled()
+ })
+})
diff --git a/src/components/RichText/Editor/__tests__/convertCtrlKey.spec.js b/src/components/RichText/Editor/__tests__/convertCtrlKey.spec.js
new file mode 100644
index 000000000..5ebc93a2b
--- /dev/null
+++ b/src/components/RichText/Editor/__tests__/convertCtrlKey.spec.js
@@ -0,0 +1,230 @@
+import { convertCtrlKey } from '../markdownHandler.js'
+
+describe('convertCtrlKey', () => {
+ it('does not trigger callback if no ctrl key', () => {
+ const cb = jest.fn()
+ const e = { key: 'j', preventDefault: () => {} }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).not.toHaveBeenCalled()
+ })
+
+ describe('when ctrl key + "b" pressed', () => {
+ it('triggers callback with open/close markers and caret pos in between', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ ctrlKey: true,
+ target: {
+ selectionStart: 0,
+ selectionEnd: 0,
+ value: 'rainbow dash',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('** rainbow dash', 1)
+ })
+
+ it('triggers callback with open/close markers and caret pos in between (end of text)', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ ctrlKey: true,
+ target: {
+ selectionStart: 22,
+ selectionEnd: 22,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow dash is purple **', 24)
+ })
+
+ it('triggers callback with open/close markers mid-text with surrounding spaces (1)', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 4, // caret located just before "quick"
+ selectionEnd: 4,
+ value: 'the quick brown fox',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('the ** quick brown fox', 5)
+ })
+
+ it('triggers callback with open/close markers mid-text with surrounding spaces (2)', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 3, // caret located just after "the"
+ selectionEnd: 3,
+ value: 'the quick brown fox',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('the ** quick brown fox', 5)
+ })
+
+ it('triggers callback with correct double markers and padding', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 9, // between the underscores
+ selectionEnd: 9,
+ value: 'rainbow __',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow _**_', 10)
+ })
+
+ describe('selected text', () => {
+ it('triggers callback with open/close markers around text and caret pos after closing marker', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 5, // "ow da" is selected
+ selectionEnd: 10,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith(
+ 'rainb *ow da* sh is purple',
+ 13
+ )
+ })
+
+ it('triggers callback with open/close markers around text when starting at beginning of line', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 0, // "rainbow" is selected
+ selectionEnd: 7,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('*rainbow* dash is purple', 9)
+ })
+
+ it('triggers callback with open/close markers around text when ending at end of line', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 16, // "purple" is selected
+ selectionEnd: 22,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow dash is *purple*', 24)
+ })
+
+ it('triggers callback with open/close markers around word', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 8, // "dash" is selected
+ selectionEnd: 12,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow *dash* is purple', 14)
+ })
+
+ it('triggers callback with leading/trailing spaces trimmed from selection', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'b',
+ metaKey: true,
+ target: {
+ selectionStart: 8, // " dash " is selected (note leading and trailing space)
+ selectionEnd: 13,
+ value: 'rainbow dash is purple',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('rainbow *dash* is purple', 14)
+ })
+ })
+ })
+
+ describe('when ctrl key + "i" pressed', () => {
+ it('triggers callback with open/close italics markers and caret pos in between', () => {
+ const cb = jest.fn()
+ const e = {
+ key: 'i',
+ ctrlKey: true,
+ target: {
+ selectionStart: 0,
+ selectionEnd: 0,
+ value: '',
+ },
+ preventDefault: () => {},
+ }
+
+ convertCtrlKey(e, cb)
+
+ expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledWith('__', 1)
+ })
+ })
+})
diff --git a/src/components/Interpretations/common/RichTextEditor/markdownHandler.js b/src/components/RichText/Editor/markdownHandler.js
similarity index 86%
rename from src/components/Interpretations/common/RichTextEditor/markdownHandler.js
rename to src/components/RichText/Editor/markdownHandler.js
index e32a771a5..f9b01895f 100644
--- a/src/components/Interpretations/common/RichTextEditor/markdownHandler.js
+++ b/src/components/RichText/Editor/markdownHandler.js
@@ -89,14 +89,20 @@ export const insertMarkdown = (markdown, target, cb) => {
if (start === end) {
//no text
const valueArr = value.split('')
- let markdown = marker.prefix
+ let markdownString = marker.prefix
if (marker.postfix) {
- markdown += marker.postfix
+ markdownString += marker.postfix
}
- valueArr.splice(start, 0, padMarkers(markdown))
+ valueArr.splice(start, 0, padMarkers(markdownString))
newValue = valueArr.join('')
+
+ // for smileys, put the caret after a space
+ if (Object.keys(emojis).includes(markdown)) {
+ newValue += ' '
+ caretPos = caretPos + newValue.length - 1
+ }
} else {
const text = value.slice(start, end)
const trimmedText = trim(text) // TODO really needed?
@@ -104,15 +110,15 @@ export const insertMarkdown = (markdown, target, cb) => {
// adjust caretPos based on trimmed text selection
caretPos = caretPos - (text.length - trimmedText.length) + 1
- let markdown = `${marker.prefix}${trimmedText}`
+ let markdownString = `${marker.prefix}${trimmedText}`
if (marker.postfix) {
- markdown += marker.postfix
+ markdownString += marker.postfix
}
newValue = [
value.slice(0, start),
- padMarkers(markdown),
+ padMarkers(markdownString),
value.slice(end),
].join('')
}
diff --git a/src/components/Interpretations/common/RichTextEditor/styles/RichTextEditor.style.js b/src/components/RichText/Editor/styles/Editor.style.js
similarity index 81%
rename from src/components/Interpretations/common/RichTextEditor/styles/RichTextEditor.style.js
rename to src/components/RichText/Editor/styles/Editor.style.js
index 865e484aa..607be4c8d 100644
--- a/src/components/Interpretations/common/RichTextEditor/styles/RichTextEditor.style.js
+++ b/src/components/RichText/Editor/styles/Editor.style.js
@@ -6,18 +6,29 @@ export const mainClasses = css`
display: flex;
flex-direction: column;
width: 100%;
+ height: 100%;
}
.preview {
+ padding: ${spacers.dp8} ${spacers.dp12};
font-size: 14px;
- line-height: 19px;
+ line-height: ${spacers.dp16};
color: ${colors.grey900};
+ overflow-y: auto;
+ scroll-behavior: smooth;
+ }
+
+ .edit {
+ width: 100%;
+ height: 100%;
+ scroll-behavior: smooth;
}
.textarea {
width: 100%;
+ height: 100%;
box-sizing: border-box;
- padding: ${spacers.dp8} ${spacers.dp12};
+ padding: ${spacers.dp8} 15px;
color: ${colors.grey900};
background-color: ${colors.white};
@@ -31,11 +42,20 @@ export const mainClasses = css`
font-size: 14px;
line-height: ${spacers.dp16};
user-select: text;
+ resize: none;
+ }
+
+ .textarea.resizable {
+ resize: vertical;
}
.textarea:focus {
outline: none;
box-shadow: 0 0 0 3px ${theme.focus};
+ width: calc(100% - 6px);
+ height: calc(100% - 3px);
+ padding: ${spacers.dp8} ${spacers.dp12};
+ margin-left: 3px;
}
.textarea:disabled {
diff --git a/src/components/RichText/Parser/MdParser.js b/src/components/RichText/Parser/MdParser.js
new file mode 100644
index 000000000..0ec97a5f6
--- /dev/null
+++ b/src/components/RichText/Parser/MdParser.js
@@ -0,0 +1,125 @@
+import MarkdownIt from 'markdown-it'
+
+const emojiDb = {
+ ':-)': '\u{1F642}',
+ ':)': '\u{1F642}',
+ ':-(': '\u{1F641}',
+ ':(': '\u{1F641}',
+ ':+1': '\u{1F44D}',
+ ':-1': '\u{1F44E}',
+}
+
+const codes = {
+ bold: {
+ name: 'bold',
+ char: '*',
+ domEl: 'strong',
+ encodedChar: 0x2a,
+ // see https://regex101.com/r/evswdV/8 for explanation of regexp
+ regexString: '\\B\\*((?!\\s)[^*]+(?:\\b|[^*\\s]))\\*\\B',
+ contentFn: (val) => val,
+ },
+ italic: {
+ name: 'italic',
+ char: '_',
+ domEl: 'em',
+ encodedChar: 0x5f,
+ // see https://regex101.com/r/p6LpjK/6 for explanation of regexp
+ regexString: '\\b_((?!\\s)[^_]+(?:\\B|[^_\\s]))_\\b',
+ contentFn: (val) => val,
+ },
+ emoji: {
+ name: 'emoji',
+ char: ':',
+ domEl: 'span',
+ encodedChar: 0x3a,
+ regexString: '^(:-\\)|:\\)|:\\(|:-\\(|:\\+1|:-1)',
+ contentFn: (val) => emojiDb[val],
+ },
+}
+
+let linksInText
+
+const markerIsInLinkText = (pos) =>
+ linksInText.some((link) => pos >= link.index && pos <= link.lastIndex)
+
+const parse = (code) => (state, silent) => {
+ if (silent) {
+ return false
+ }
+
+ const start = state.pos
+
+ // skip parsing emphasis if marker is within a link
+ if (markerIsInLinkText(start)) {
+ return false
+ }
+
+ const marker = state.src.charCodeAt(start)
+
+ // marker character: "_", "*", ":"
+ if (marker !== codes[code].encodedChar) {
+ return false
+ }
+
+ const MARKER_REGEX = new RegExp(codes[code].regexString)
+ const token = state.src.slice(start)
+
+ if (MARKER_REGEX.test(token)) {
+ const markerMatch = token.match(MARKER_REGEX)
+
+ // skip parsing sections where the marker is not at the start of the token
+ if (markerMatch.index !== 0) {
+ return false
+ }
+
+ const text = markerMatch[1]
+
+ state.push(`${codes[code].domEl}_open`, codes[code].domEl, 1)
+
+ const t = state.push('text', '', 0)
+ t.content = codes[code].contentFn(text)
+
+ state.push(`${codes.bold.domEl}_close`, codes[code].domEl, -1)
+ state.pos += markerMatch[0].length
+
+ return true
+ }
+
+ return false
+}
+
+export class MdParser {
+ constructor() {
+ // disable all rules, enable autolink for URLs and email addresses
+ const md = new MarkdownIt('zero', { linkify: true, breaks: true })
+
+ // *bold* ->
bold
+ md.inline.ruler.push('strong', parse(codes.bold.name))
+
+ // _italic_ ->
italic
+ md.inline.ruler.push('italic', parse(codes.italic.name))
+
+ // :-) :) :-( :( :+1 :-1 ->
[unicode]
+ md.inline.ruler.push('emoji', parse(codes.emoji.name))
+
+ md.enable([
+ 'heading',
+ 'link',
+ 'linkify',
+ 'list',
+ 'newline',
+ 'strong',
+ 'italic',
+ 'emoji',
+ ])
+
+ this.md = md
+ }
+
+ render(text) {
+ linksInText = this.md.linkify.match(text) || []
+
+ return this.md.render(text)
+ }
+}
diff --git a/src/components/RichText/Parser/Parser.js b/src/components/RichText/Parser/Parser.js
new file mode 100644
index 000000000..172049bc1
--- /dev/null
+++ b/src/components/RichText/Parser/Parser.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types'
+import React, { useMemo } from 'react'
+import { MdParser } from './MdParser.js'
+
+export const Parser = ({ children, style }) => {
+ const MdParserInstance = useMemo(() => new MdParser(), [])
+
+ return children ? (
+
+ ) : null
+}
+
+Parser.defaultProps = {
+ style: null,
+}
+
+Parser.propTypes = {
+ children: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.node),
+ PropTypes.node,
+ ]),
+ style: PropTypes.object,
+}
diff --git a/src/components/RichText/Parser/__tests__/MdParser.spec.js b/src/components/RichText/Parser/__tests__/MdParser.spec.js
new file mode 100644
index 000000000..397b27e76
--- /dev/null
+++ b/src/components/RichText/Parser/__tests__/MdParser.spec.js
@@ -0,0 +1,166 @@
+import { MdParser } from '../MdParser.js'
+
+const Parser = new MdParser()
+
+describe('MdParser class', () => {
+ it('converts text into HTML', () => {
+ const inlineTests = [
+ ['_italic_', '
italic'],
+ ['*bold*', '
bold'],
+ [
+ '_ not italic because there is a space _',
+ '_ not italic because there is a space _',
+ ],
+ [':-)', '
\u{1F642}'],
+ [':)', '
\u{1F642}'],
+ [':-(', '
\u{1F641}'],
+ [':(', '
\u{1F641}'],
+ [':+1', '
\u{1F44D}'],
+ [':-1', '
\u{1F44E}'],
+ [
+ 'mixed _italic_ *bold* and :+1',
+ 'mixed
italic bold and
\u{1F44D}',
+ ],
+ ['_italic with * inside_', '
italic with * inside'],
+ ['*bold with _ inside*', '
bold with _ inside'],
+
+ // italic marker followed by : should work
+ ['_italic_:', '
italic:'],
+ [
+ '_italic_: some text, *bold*: some other text',
+ '
italic: some text,
bold: some other text',
+ ],
+ // bold marker followed by : should work
+ ['*bold*:', '
bold:'],
+ [
+ '*bold*: some text, _italic_: some other text',
+ '
bold: some text,
italic: some other text',
+ ],
+
+ // italic marker inside an italic string not allowed
+ ['_italic with _ inside_', '_italic with _ inside_'],
+ // bold marker inside a bold string not allowed
+ ['*bold with * inside*', '*bold with * inside*'],
+ [
+ '_multiple_ italic in the _same line_',
+ '
multiple italic in the
same line',
+ ],
+ // nested italic/bold combinations not allowed
+ [
+ '_italic with *bold* inside_',
+ '
italic with *bold* inside',
+ ],
+ [
+ '*bold with _italic_ inside*',
+ '
bold with _italic_ inside',
+ ],
+ ['text with : and :)', 'text with : and
\u{1F642}'],
+ [
+ '(parenthesis and :))',
+ '(parenthesis and
\u{1F642})',
+ ],
+ [
+ ':((parenthesis:))',
+ '
\u{1F641}(parenthesis
\u{1F642})',
+ ],
+ [':+1+1', '
\u{1F44D}+1'],
+ ['-1:-1', '-1
\u{1F44E}'],
+
+ // links
+ [
+ 'example.com/path',
+ '
example.com/path',
+ ],
+
+ // not recognized links with italic marker inside not converted
+ [
+ 'example_with_underscore.com/path',
+ 'example_with_underscore.com/path',
+ ],
+ [
+ 'example_with_underscore.com/path_with_underscore',
+ 'example_with_underscore.com/path_with_underscore',
+ ],
+
+ // markers around non-recognized links
+ [
+ 'link example_with_underscore.com/path should _not_ be converted',
+ 'link example_with_underscore.com/path should
not be converted',
+ ],
+ [
+ 'link example_with_underscore.com/path should *not* be converted',
+ 'link example_with_underscore.com/path should
not be converted',
+ ],
+
+ // italic marker inside links not converted
+ [
+ 'example.com/path_with_underscore',
+ '
example.com/path_with_underscore',
+ ],
+ [
+ '_italic_ and *bold* with a example.com/link_with_underscore',
+ '
italic and
bold with a
example.com/link_with_underscore',
+ ],
+ [
+ 'example.com/path with *bold* after :)',
+ '
example.com/path with
bold after
\u{1F642}',
+ ],
+ [
+ '_before_ example.com/path_with_underscore *after* :)',
+ '
before example.com/path_with_underscore after \u{1F642}',
+ ],
+
+ // italic/bold markers right after non-word characters
+ [
+ '_If % of ART retention rate after 12 months >90(%)_: Sustain the efforts.',
+ '
If % of ART retention rate after 12 months >90(%): Sustain the efforts.',
+ ],
+ [
+ '*If % of ART retention rate after 12 months >90(%)*: Sustain the efforts.',
+ '
If % of ART retention rate after 12 months >90(%): Sustain the efforts.',
+ ],
+ ]
+
+ inlineTests.forEach((test) => {
+ const renderedText = Parser.render(test[0])
+
+ expect(renderedText).toEqual(`
${test[1]}
\n`)
+ })
+
+ const blockTests = [
+ // heading
+ ['# Heading 1', '
Heading 1
'],
+ ['## Heading 2', '
Heading 2
'],
+ ['### Heading 3', '
Heading 3
'],
+ ['#### Heading 4', '
Heading 4
'],
+ ['##### Heading 5', '
Heading 5
'],
+ ['###### Heading 6', '
Heading 6
'],
+ ['# *Bold head*', '
Bold head
'],
+ ['## _Italic title_', '
Italic title
'],
+ [
+ '### *Bold* and _italic_ title',
+ '
Bold and italic title
',
+ ],
+
+ // lists
+ [
+ '* first\n* second\n* third',
+ '
',
+ ],
+ [
+ '1. one\n1. two\n1. three\n',
+ '
\n- one
\n- two
\n- three
\n
',
+ ],
+ [
+ '* *first*\n* second\n* _third_',
+ '
',
+ ],
+ ]
+
+ blockTests.forEach((test) => {
+ const renderedText = Parser.render(test[0])
+
+ expect(renderedText).toEqual(`${test[1]}\n`)
+ })
+ })
+})
diff --git a/src/components/RichText/Parser/__tests__/Parser.spec.js b/src/components/RichText/Parser/__tests__/Parser.spec.js
new file mode 100644
index 000000000..26011d492
--- /dev/null
+++ b/src/components/RichText/Parser/__tests__/Parser.spec.js
@@ -0,0 +1,43 @@
+import { shallow } from 'enzyme'
+import React from 'react'
+import { Parser } from '../Parser.js'
+
+jest.mock('../MdParser.js', () => ({
+ MdParser: jest.fn().mockImplementation(() => {
+ return { render: () => 'converted text' }
+ }),
+}))
+
+describe('RichText: Parser component', () => {
+ let richTextParser
+ const defaultProps = {
+ style: { color: 'blue', whiteSpace: 'pre-line' },
+ }
+
+ const renderComponent = (props, text) => {
+ return shallow(
{text})
+ }
+
+ it('should have rendered a result', () => {
+ richTextParser = renderComponent({}, 'test')
+
+ expect(richTextParser).toHaveLength(1)
+ })
+
+ it('should have rendered a result with the style prop', () => {
+ richTextParser = renderComponent(defaultProps, 'test prop')
+
+ expect(richTextParser.props().style).toEqual(defaultProps.style)
+ })
+
+ it('should have rendered content', () => {
+ richTextParser = renderComponent({}, 'plain text')
+
+ expect(richTextParser.html()).toEqual('
converted text
')
+ })
+
+ it('should return null if no children is passed', () => {
+ richTextParser = renderComponent({}, undefined)
+ expect(richTextParser.html()).toBe(null)
+ })
+})
diff --git a/src/components/RichText/index.js b/src/components/RichText/index.js
new file mode 100644
index 000000000..6d9ff0e75
--- /dev/null
+++ b/src/components/RichText/index.js
@@ -0,0 +1,3 @@
+export { Editor as RichTextEditor } from './Editor/Editor.js'
+export { Parser as RichTextParser } from './Parser/Parser.js'
+export { MdParser as RichTextMdParser } from './Parser/MdParser.js'
diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js b/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js
index 259131be2..fbcdf146c 100644
--- a/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js
+++ b/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js
@@ -6,7 +6,13 @@ import React, { useRef } from 'react'
import menuButtonStyles from '../MenuButton.styles.js'
import { useHoverMenubarContext } from './HoverMenuBar.js'
-export const HoverMenuDropdown = ({ children, label, dataTest, disabled }) => {
+export const HoverMenuDropdown = ({
+ children,
+ className,
+ label,
+ dataTest,
+ disabled,
+}) => {
const buttonRef = useRef()
const {
onDropDownButtonClick,
@@ -18,7 +24,7 @@ export const HoverMenuDropdown = ({ children, label, dataTest, disabled }) => {
return (
<>
', () => {
expect(wrapper.find('button').prop('data-test')).toBe(dataTest)
})
+
+ it('accepts a `className` prop', () => {
+ const className = 'test'
+ const wrapper = shallow(
+
+ children
+
+ )
+
+ expect(wrapper.find('button')).toHaveClassName(className)
+ })
})
diff --git a/src/components/Interpretations/common/UserMention/UserList.js b/src/components/UserMention/UserList.js
similarity index 100%
rename from src/components/Interpretations/common/UserMention/UserList.js
rename to src/components/UserMention/UserList.js
diff --git a/src/components/Interpretations/common/UserMention/UserMentionWrapper.js b/src/components/UserMention/UserMentionWrapper.js
similarity index 88%
rename from src/components/Interpretations/common/UserMention/UserMentionWrapper.js
rename to src/components/UserMention/UserMentionWrapper.js
index 394cc39bc..4550d5513 100644
--- a/src/components/Interpretations/common/UserMention/UserMentionWrapper.js
+++ b/src/components/UserMention/UserMentionWrapper.js
@@ -2,12 +2,12 @@ import i18n from '@dhis2/d2-i18n'
import {
CenteredContent,
CircularLoader,
+ Layer,
Menu,
MenuSectionHeader,
MenuItem,
Popper,
Card,
- Portal,
} from '@dhis2/ui'
import PropTypes from 'prop-types'
import React, { useState, useRef } from 'react'
@@ -43,6 +43,7 @@ export const UserMentionWrapper = ({
inputReference,
onUserSelect,
}) => {
+ const [listIsOpen, setListIsOpen] = useState(false)
const [captureText, setCaptureText] = useState(false)
const [capturedText, setCapturedText] = useState('')
const [cloneText, setCloneText] = useState('')
@@ -54,6 +55,7 @@ export const UserMentionWrapper = ({
})
const reset = () => {
+ setListIsOpen(false)
setCaptureText(false)
setCapturedText('')
setCloneText('')
@@ -63,6 +65,12 @@ export const UserMentionWrapper = ({
clear()
}
+ // focus the input/textarea when the user list is closed by clicking above the input/textarea
+ const onClick = () => inputReference.current.focus()
+
+ // close the user list when clicking in the input/textarea or outside of it (input/textarea blur)
+ const onUserListClose = () => reset()
+
// event bubbles up from the input/textarea
const onInput = ({ target }) => {
const { selectionEnd, value } = target
@@ -72,10 +80,12 @@ export const UserMentionWrapper = ({
const spacePosition = value.indexOf(' ', captureStartPosition - 1)
- const filterValue = value.substring(
- captureStartPosition,
- spacePosition > 0 ? spacePosition : selectionEnd + 1
- )
+ const filterValue = value
+ .substring(
+ captureStartPosition,
+ spacePosition > 0 ? spacePosition : selectionEnd + 1
+ )
+ .replace(/\n+/, '')
if (filterValue !== capturedText) {
setCapturedText(filterValue)
@@ -91,6 +101,7 @@ export const UserMentionWrapper = ({
const { selectionStart } = target
if (!captureText && key === '@') {
+ setListIsOpen(true)
setCaptureText(true)
setCaptureStartPosition(selectionStart + 1)
setCloneText(target.value.substring(0, selectionStart) + '@')
@@ -159,16 +170,21 @@ export const UserMentionWrapper = ({
)
}
- const onClick = (user) => () => onSelect(user)
+ const onUserClick = (user) => () => onSelect(user)
return (
-
+
{children}
-
{cloneText}
+
{cloneText}
- {captureText && (
-
+ {listIsOpen && (
+
)}
@@ -228,7 +244,7 @@ export const UserMentionWrapper = ({
-
+
)}
{resolvedHeaderStyle.styles}
@@ -245,5 +261,3 @@ UserMentionWrapper.propTypes = {
onUserSelect: PropTypes.func.isRequired,
children: PropTypes.node,
}
-
-export default UserMentionWrapper
diff --git a/src/components/Interpretations/common/UserMention/styles/UserMentionWrapper.style.js b/src/components/UserMention/styles/UserMentionWrapper.style.js
similarity index 88%
rename from src/components/Interpretations/common/UserMention/styles/UserMentionWrapper.style.js
rename to src/components/UserMention/styles/UserMentionWrapper.style.js
index e98517db1..a075ff11d 100644
--- a/src/components/Interpretations/common/UserMention/styles/UserMentionWrapper.style.js
+++ b/src/components/UserMention/styles/UserMentionWrapper.style.js
@@ -8,6 +8,8 @@ import css from 'styled-jsx/css'
*/
export const userMentionWrapperClasses = css`
.wrapper {
+ width: 100%;
+ height: 100%;
position: relative;
}
.clone {
@@ -15,19 +17,20 @@ export const userMentionWrapperClasses = css`
visibility: hidden;
inset: 0;
box-sizing: border-box;
- padding: ${spacers.dp8} ${spacers.dp12};
+ padding: ${spacers.dp8} 15px;
border: 1px solid ${colors.grey500};
font-size: 14px;
line-height: ${spacers.dp16};
z-index: 1;
pointer-events: none;
}
- .clone > pre {
+ .clone > p {
display: inline;
word-wrap: break-word;
overflow-wrap: break-word;
font: inherit;
margin: 0;
+ white-space: break-spaces;
}
.container {
background-color: ${colors.white};
diff --git a/src/components/Interpretations/common/UserMention/useUserSearchResults.js b/src/components/UserMention/useUserSearchResults.js
similarity index 94%
rename from src/components/Interpretations/common/UserMention/useUserSearchResults.js
rename to src/components/UserMention/useUserSearchResults.js
index b9d46b46d..9adfc4613 100644
--- a/src/components/Interpretations/common/UserMention/useUserSearchResults.js
+++ b/src/components/UserMention/useUserSearchResults.js
@@ -32,10 +32,10 @@ export const useUserSearchResults = ({ searchText }) => {
}, [searchText, debouncedRefetch])
useEffect(() => {
- if (data) {
+ if (fetching === false && data) {
setData(data.users)
}
- }, [data])
+ }, [data, fetching])
return {
users,
diff --git a/src/components/VisTypeIcon.js b/src/components/VisTypeIcon.js
index 3949a29f3..b9475ba78 100644
--- a/src/components/VisTypeIcon.js
+++ b/src/components/VisTypeIcon.js
@@ -1,5 +1,4 @@
import {
- IconTable16,
IconVisualizationArea16,
IconVisualizationAreaStacked16,
IconVisualizationBar16,
@@ -11,11 +10,12 @@ import {
IconVisualizationLine16,
IconVisualizationLinelist16,
IconVisualizationLineMulti16,
+ IconVisualizationOutlierTable16,
IconVisualizationPie16,
+ IconVisualizationPivotTable16,
IconVisualizationRadar16,
IconVisualizationScatter16,
IconVisualizationSingleValue16,
- IconTable24,
IconVisualizationArea24,
IconVisualizationAreaStacked24,
IconVisualizationBar24,
@@ -27,7 +27,9 @@ import {
IconVisualizationGauge24,
IconVisualizationLine24,
IconVisualizationLineMulti24,
+ IconVisualizationOutlierTable24,
IconVisualizationPie24,
+ IconVisualizationPivotTable24,
IconVisualizationRadar24,
IconVisualizationScatter24,
IconVisualizationSingleValue24,
@@ -51,6 +53,7 @@ import {
VIS_TYPE_YEAR_OVER_YEAR_COLUMN,
VIS_TYPE_SINGLE_VALUE,
VIS_TYPE_SCATTER,
+ VIS_TYPE_OUTLIER_TABLE,
} from '../modules/visTypes.js'
export const VisTypeIcon = ({ type, useSmall = false, ...props }) => {
@@ -64,7 +67,9 @@ export const VisTypeIcon = ({ type, useSmall = false, ...props }) => {
break
}
case VIS_TYPE_PIVOT_TABLE: {
- VisIcon = useSmall ? IconTable16 : IconTable24
+ VisIcon = useSmall
+ ? IconVisualizationPivotTable16
+ : IconVisualizationPivotTable24
break
}
case VIS_TYPE_BAR: {
@@ -141,6 +146,12 @@ export const VisTypeIcon = ({ type, useSmall = false, ...props }) => {
: IconVisualizationScatter24
break
}
+ case VIS_TYPE_OUTLIER_TABLE: {
+ VisIcon = useSmall
+ ? IconVisualizationOutlierTable16
+ : IconVisualizationOutlierTable24
+ break
+ }
case VIS_TYPE_COLUMN:
default: {
VisIcon = useSmall
diff --git a/src/index.js b/src/index.js
index 069656b77..1202981b5 100644
--- a/src/index.js
+++ b/src/index.js
@@ -22,6 +22,7 @@ export { default as DimensionMenu } from './components/DimensionMenu.js'
export { default as PivotTable } from './components/PivotTable/PivotTable.js'
export { default as FileMenu } from './components/FileMenu/FileMenu.js'
+export { preparePayloadForSaveAs } from './components/FileMenu/utils.js'
export { default as VisTypeIcon } from './components/VisTypeIcon.js'
@@ -29,8 +30,11 @@ export { default as LegendKey } from './components/LegendKey/LegendKey.js'
export { default as AboutAOUnit } from './components/AboutAOUnit/AboutAOUnit.js'
-export { InterpretationsUnit } from './components/Interpretations/InterpretationsUnit/InterpretationsUnit.js'
-export { InterpretationModal } from './components/Interpretations/InterpretationModal/InterpretationModal.js'
+export { InterpretationsUnit } from './components/Interpretations/InterpretationsUnit/index.js'
+export {
+ InterpretationModal,
+ InterpretationThread,
+} from './components/Interpretations/InterpretationModal/index.js'
export * from './components/Toolbar/index.js'
@@ -43,6 +47,8 @@ export {
useCachedDataQuery,
} from './components/CachedDataQueryProvider.js'
+export * from './components/RichText/index.js'
+
// Api
export { default as Analytics } from './api/analytics/Analytics.js'
@@ -202,6 +208,7 @@ export {
VIS_TYPE_PIVOT_TABLE,
VIS_TYPE_SCATTER,
VIS_TYPE_LINE_LIST,
+ VIS_TYPE_OUTLIER_TABLE,
visTypeDisplayNames,
visTypeIcons,
getDisplayNameByVisType,
@@ -211,6 +218,7 @@ export {
isYearOverYear,
isDualAxisType,
isSingleValue,
+ isOutlierTable,
isTwoCategoryChartType,
isLegendSetType,
isColumnBasedType,
@@ -227,6 +235,7 @@ export {
LAYOUT_TYPE_PIVOT_TABLE,
LAYOUT_TYPE_SCATTER,
LAYOUT_TYPE_LINE_LIST,
+ LAYOUT_TYPE_OUTLIER_TABLE,
} from './modules/layoutTypes.js'
// Modules: layoutUiRules
@@ -234,10 +243,12 @@ export {
export {
getAvailableAxes,
getDisallowedDimensions,
+ getDimensionMaxNumberOfItems,
getAxisMaxNumberOfItems,
getAxisMaxNumberOfDimensions,
getAxisMinNumberOfDimensions,
hasAxisTooManyItems,
+ hasDimensionTooManyItems,
getAxisPerLockedDimension,
getAllLockedDimensionIds,
canDimensionBeAddedToAxis,
@@ -348,4 +359,5 @@ export {
DIMENSION_TYPE_PERIOD,
DIMENSION_TYPE_ORGANISATION_UNIT_GROUP_SET,
DIMENSION_TYPE_EXPRESSION_DIMENSION_ITEM,
+ dataTypeMap,
} from './modules/dataTypes.js'
diff --git a/src/modules/__tests__/getAdaptedUiLayoutByType.spec.js b/src/modules/__tests__/getAdaptedUiLayoutByType.spec.js
index 1a5534f79..36fb256eb 100644
--- a/src/modules/__tests__/getAdaptedUiLayoutByType.spec.js
+++ b/src/modules/__tests__/getAdaptedUiLayoutByType.spec.js
@@ -15,10 +15,12 @@ import {
VIS_TYPE_BAR,
VIS_TYPE_PIE,
VIS_TYPE_SINGLE_VALUE,
+ VIS_TYPE_OUTLIER_TABLE,
} from '../visTypes.js'
const someId = 'someId'
const otherId = 'otherId'
+const thirdId = 'thirdId'
describe('getAdaptedUiLayoutByType', () => {
it('column: moves all extra dimensions in columns and rows to filters', () => {
@@ -172,6 +174,31 @@ describe('getAdaptedUiLayoutByType', () => {
expect(actualState).toEqual(expectedState)
})
+ it('outlier table: removes all dimensions but dx,pe,ou which are moved to columns', () => {
+ const initialState = {
+ [AXIS_ID_COLUMNS]: [DIMENSION_ID_DATA, someId],
+ [AXIS_ID_ROWS]: [DIMENSION_ID_PERIOD, otherId],
+ [AXIS_ID_FILTERS]: [DIMENSION_ID_ORGUNIT, thirdId],
+ }
+
+ const actualState = getAdaptedUiLayoutByType(
+ initialState,
+ VIS_TYPE_OUTLIER_TABLE
+ )
+
+ const expectedState = {
+ [AXIS_ID_COLUMNS]: [
+ DIMENSION_ID_DATA,
+ DIMENSION_ID_PERIOD,
+ DIMENSION_ID_ORGUNIT,
+ ],
+ [AXIS_ID_ROWS]: [],
+ [AXIS_ID_FILTERS]: [],
+ }
+
+ expect(actualState).toEqual(expectedState)
+ })
+
it('pivot -> sv with dimension strings', () => {
const initialLayout = {
[AXIS_ID_COLUMNS]: [DIMENSION_ID_DATA, DIMENSION_ID_PERIOD],
diff --git a/src/modules/__tests__/renderValue.spec.js b/src/modules/__tests__/renderValue.spec.js
index 845629bbe..eaf3d2a7c 100644
--- a/src/modules/__tests__/renderValue.spec.js
+++ b/src/modules/__tests__/renderValue.spec.js
@@ -25,21 +25,21 @@ const tests = [
// Numbers
{
value: 1000.5,
- expected: '1 000.5',
+ expected: '1 000.50',
valueType: VALUE_TYPE_NUMBER,
round: true,
dgs: DGS_SPACE,
},
{
- value: 33777889.55,
- expected: '33,777,889.5',
+ value: 33777889.555,
+ expected: '33,777,889.55',
valueType: VALUE_TYPE_NUMBER,
round: true,
dgs: DGS_COMMA,
},
{
value: 33777889.556,
- expected: '33 777 889.6',
+ expected: '33 777 889.56',
valueType: VALUE_TYPE_NUMBER,
round: true,
dgs: DGS_SPACE,
@@ -53,7 +53,7 @@ const tests = [
},
{
value: 33777889.56,
- expected: '33777889.6',
+ expected: '33777889.56',
valueType: VALUE_TYPE_NUMBER,
round: true,
dgs: DGS_NONE,
@@ -74,7 +74,7 @@ const tests = [
},
{
value: 1.101,
- expected: '1.1',
+ expected: '1.10',
valueType: VALUE_TYPE_NUMBER,
round: true,
dgs: DGS_SPACE,
@@ -135,16 +135,16 @@ const tests = [
dgs: DGS_SPACE,
},
{
- value: -0.0234,
- expected: '-2.3%',
+ value: -0.02345,
+ expected: '-2.34%',
valueType: VALUE_TYPE_NUMBER,
numberType: NUMBER_TYPE_ROW_PERCENTAGE,
round: true,
dgs: DGS_SPACE,
},
{
- value: -0.0234,
- expected: '-2.34%',
+ value: -0.02345,
+ expected: '-2.345%',
valueType: VALUE_TYPE_NUMBER,
numberType: NUMBER_TYPE_ROW_PERCENTAGE,
round: false,
diff --git a/src/modules/axis.js b/src/modules/axis.js
index 6dfe47cfe..0b68cd3f1 100644
--- a/src/modules/axis.js
+++ b/src/modules/axis.js
@@ -11,6 +11,7 @@ import {
LAYOUT_TYPE_PIVOT_TABLE,
LAYOUT_TYPE_SCATTER,
LAYOUT_TYPE_LINE_LIST,
+ LAYOUT_TYPE_OUTLIER_TABLE,
} from './layoutTypes.js'
const getAxisNamesByLayoutType = (layoutType) => {
@@ -40,6 +41,10 @@ const getAxisNamesByLayoutType = (layoutType) => {
[AXIS_ID_ROWS]: i18n.t('Points'),
[AXIS_ID_FILTERS]: i18n.t('Filter'),
}
+ case LAYOUT_TYPE_OUTLIER_TABLE:
+ return {
+ [AXIS_ID_COLUMNS]: i18n.t('Columns'),
+ }
}
}
diff --git a/src/modules/getAdaptedUiLayoutByType.js b/src/modules/getAdaptedUiLayoutByType.js
index 42ef2e546..9f1d72028 100644
--- a/src/modules/getAdaptedUiLayoutByType.js
+++ b/src/modules/getAdaptedUiLayoutByType.js
@@ -17,6 +17,7 @@ import {
VIS_TYPE_SINGLE_VALUE,
VIS_TYPE_PIVOT_TABLE,
VIS_TYPE_SCATTER,
+ VIS_TYPE_OUTLIER_TABLE,
isTwoCategoryChartType,
} from './visTypes.js'
@@ -40,6 +41,8 @@ export const getAdaptedUiLayoutByType = (layout, type) => {
return layout
case VIS_TYPE_SCATTER:
return getScatterLayout(layout)
+ case VIS_TYPE_OUTLIER_TABLE:
+ return getOutlierTableLayout(layout)
default:
return getDefaultLayout(layout)
}
@@ -140,6 +143,17 @@ const getSingleValueLayout = (layout) => {
}
}
+// Transform from ui.layout to outlier table layout format
+const getOutlierTableLayout = () => ({
+ [AXIS_ID_COLUMNS]: [
+ DIMENSION_ID_DATA,
+ DIMENSION_ID_PERIOD,
+ DIMENSION_ID_ORGUNIT,
+ ],
+ [AXIS_ID_ROWS]: [],
+ [AXIS_ID_FILTERS]: [],
+})
+
/**
*
* @param {string|object} dimension
diff --git a/src/modules/layout/axisGetDimensionIds.js b/src/modules/layout/axisGetDimensionIds.js
index 0c7023e45..3db877e8a 100644
--- a/src/modules/layout/axisGetDimensionIds.js
+++ b/src/modules/layout/axisGetDimensionIds.js
@@ -1,7 +1,7 @@
import { AXIS } from './axis.js'
import { dimensionGetId } from './dimensionGetId.js'
-export const axisGetDimensionIds = (axis) =>
+export const axisGetDimensionIds = (axis, outputType) =>
AXIS.isValid(axis)
- ? axis.map((dimension) => dimensionGetId(dimension))
+ ? axis.map((dimension) => dimensionGetId(dimension, outputType))
: AXIS.defaultValue
diff --git a/src/modules/layout/dimension.js b/src/modules/layout/dimension.js
index 5133b4759..fb6454467 100644
--- a/src/modules/layout/dimension.js
+++ b/src/modules/layout/dimension.js
@@ -37,6 +37,13 @@ export const DIMENSION_PROP_LEGEND_SET = {
isValid: (prop) => isString(prop),
}
+export const DIMENSION_PROP_PROGRAM = {
+ name: 'program',
+ defaultValue: {},
+ required: false,
+ isValid: (prop) => isObject(prop),
+}
+
export const DIMENSION_PROP_PROGRAM_STAGE = {
name: 'programStage',
defaultValue: {},
@@ -56,6 +63,7 @@ export const DIMENSION_PROPS = [
DIMENSION_PROP_ITEMS,
DIMENSION_PROP_FILTER,
DIMENSION_PROP_LEGEND_SET,
+ DIMENSION_PROP_PROGRAM,
DIMENSION_PROP_PROGRAM_STAGE,
DIMENSION_PROP_REPETITION,
]
diff --git a/src/modules/layout/dimensionCreate.js b/src/modules/layout/dimensionCreate.js
index 7fb11b39c..261edeb3b 100644
--- a/src/modules/layout/dimensionCreate.js
+++ b/src/modules/layout/dimensionCreate.js
@@ -3,6 +3,7 @@ import {
DIMENSION_PROP_ITEMS,
DIMENSION_PROP_FILTER,
DIMENSION_PROP_LEGEND_SET,
+ DIMENSION_PROP_PROGRAM,
DIMENSION_PROP_PROGRAM_STAGE,
DIMENSION_PROP_REPETITION,
} from './dimension.js'
@@ -17,6 +18,9 @@ export const dimensionCreate = (dimensionId, itemIds = [], args = {}) => {
...(args.legendSet && {
[DIMENSION_PROP_LEGEND_SET.name]: args.legendSet,
}),
+ ...(args.program && {
+ [DIMENSION_PROP_PROGRAM.name]: args.program,
+ }),
...(args.programStage && {
[DIMENSION_PROP_PROGRAM_STAGE.name]: args.programStage,
}),
diff --git a/src/modules/layout/dimensionGetId.js b/src/modules/layout/dimensionGetId.js
index 2354a860d..f5d63b860 100644
--- a/src/modules/layout/dimensionGetId.js
+++ b/src/modules/layout/dimensionGetId.js
@@ -1,8 +1,14 @@
-import { DIMENSION_PROP_ID, DIMENSION_PROP_PROGRAM_STAGE } from './dimension.js'
+import { formatDimension } from '../../api/analytics/utils.js'
+import {
+ DIMENSION_PROP_ID,
+ DIMENSION_PROP_PROGRAM_STAGE,
+ DIMENSION_PROP_PROGRAM,
+} from './dimension.js'
-export const dimensionGetId = (dimension) =>
- dimension[DIMENSION_PROP_PROGRAM_STAGE.name]?.id
- ? `${dimension[DIMENSION_PROP_PROGRAM_STAGE.name].id}.${
- dimension[DIMENSION_PROP_ID.name]
- }`
- : dimension[DIMENSION_PROP_ID.name]
+export const dimensionGetId = (dimension, outputType) =>
+ formatDimension({
+ dimension: dimension[DIMENSION_PROP_ID.name],
+ programId: dimension[DIMENSION_PROP_PROGRAM.name]?.id,
+ programStageId: dimension[DIMENSION_PROP_PROGRAM_STAGE.name]?.id,
+ outputType,
+ })
diff --git a/src/modules/layout/layoutFilterDimensions.js b/src/modules/layout/layoutFilterDimensions.js
index 0e9cd9250..ab8f9ff65 100644
--- a/src/modules/layout/layoutFilterDimensions.js
+++ b/src/modules/layout/layoutFilterDimensions.js
@@ -8,7 +8,10 @@ export const layoutFilterDimensions = (layout, dimensionIds) => {
DEFAULT_AXIS_IDS.forEach((axisId) => {
if (AXIS.isValid(filteredLayout[axisId])) {
filteredLayout[axisId] = filteredLayout[axisId].filter(
- (dimension) => !idArray.includes(dimensionGetId(dimension))
+ (dimension) =>
+ !idArray.includes(
+ dimensionGetId(dimension, layout.outputType)
+ )
)
}
})
diff --git a/src/modules/layout/layoutGetAxisIdDimensionIdsObject.js b/src/modules/layout/layoutGetAxisIdDimensionIdsObject.js
index f7ca0c8ad..c875b0567 100644
--- a/src/modules/layout/layoutGetAxisIdDimensionIdsObject.js
+++ b/src/modules/layout/layoutGetAxisIdDimensionIdsObject.js
@@ -4,7 +4,7 @@ import { axisGetDimensionIds } from './axisGetDimensionIds.js'
export const layoutGetAxisIdDimensionIdsObject = (layout) =>
DEFAULT_AXIS_IDS.reduce((obj, axisId) => {
if (AXIS.isValid(layout[axisId])) {
- obj[axisId] = axisGetDimensionIds(layout[axisId])
+ obj[axisId] = axisGetDimensionIds(layout[axisId], layout.outputType)
}
return obj
diff --git a/src/modules/layout/layoutGetDimensionIdItemIdsObject.js b/src/modules/layout/layoutGetDimensionIdItemIdsObject.js
index c98e1650e..0bb9e5724 100644
--- a/src/modules/layout/layoutGetDimensionIdItemIdsObject.js
+++ b/src/modules/layout/layoutGetDimensionIdItemIdsObject.js
@@ -4,6 +4,7 @@ import { layoutGetAllDimensions } from './layoutGetAllDimensions.js'
export const layoutGetDimensionIdItemIdsObject = (layout) =>
layoutGetAllDimensions(layout).reduce((obj, dimension) => {
- obj[dimensionGetId(dimension)] = dimensionGetItemIds(dimension)
+ obj[dimensionGetId(dimension, layout.outputType)] =
+ dimensionGetItemIds(dimension)
return obj
}, {})
diff --git a/src/modules/layout/layoutHasDynamicDimension.js b/src/modules/layout/layoutHasDynamicDimension.js
index 586abdd1f..dc02b5f94 100644
--- a/src/modules/layout/layoutHasDynamicDimension.js
+++ b/src/modules/layout/layoutHasDynamicDimension.js
@@ -7,7 +7,8 @@ export const layoutHasDynamicDimension = (layout) => {
return Boolean(
layoutGetAllDimensions(layout).find(
- (dimension) => !fixedIds.includes(dimensionGetId(dimension))
+ (dimension) =>
+ !fixedIds.includes(dimensionGetId(dimension, layout.outputType))
)
)
}
diff --git a/src/modules/layoutTypes.js b/src/modules/layoutTypes.js
index fc2de5664..3ba97137d 100644
--- a/src/modules/layoutTypes.js
+++ b/src/modules/layoutTypes.js
@@ -5,3 +5,4 @@ export const LAYOUT_TYPE_YEAR_OVER_YEAR = 'LAYOUT_TYPE_YEAR_OVER_YEAR'
export const LAYOUT_TYPE_PIVOT_TABLE = 'LAYOUT_TYPE_PIVOT_TABLE'
export const LAYOUT_TYPE_SCATTER = 'LAYOUT_TYPE_SCATTER'
export const LAYOUT_TYPE_LINE_LIST = 'LAYOUT_TYPE_LINE_LIST'
+export const LAYOUT_TYPE_OUTLIER_TABLE = 'LAYOUT_TYPE_OUTLIER_TABLE'
diff --git a/src/modules/layoutUiRules/__tests__/rules.spec.js b/src/modules/layoutUiRules/__tests__/rules.spec.js
index 0b4598e91..c30d36361 100644
--- a/src/modules/layoutUiRules/__tests__/rules.spec.js
+++ b/src/modules/layoutUiRules/__tests__/rules.spec.js
@@ -3,6 +3,7 @@ import {
DIMENSION_ID_DATA,
DIMENSION_ID_ORGUNIT,
DIMENSION_ID_PERIOD,
+ DIMENSION_ID_ASSIGNED_CATEGORIES,
} from '../../predefinedDimensions.js'
import {
testResourceRules,
@@ -11,12 +12,19 @@ import {
} from '../rules.js'
// Consts
+const validDims = [DIMENSION_ID_DATA, DIMENSION_ID_PERIOD, DIMENSION_ID_ORGUNIT]
+
const lockableDims = [
DIMENSION_ID_DATA,
DIMENSION_ID_PERIOD,
DIMENSION_ID_ORGUNIT,
]
-const disallowableDims = [DIMENSION_ID_DATA, DIMENSION_ID_PERIOD]
+
+const disallowableDims = [
+ DIMENSION_ID_DATA,
+ DIMENSION_ID_PERIOD,
+ DIMENSION_ID_ASSIGNED_CATEGORIES,
+]
// Helper fns
const allArrayItemsAreValid = (allItems, validItems) =>
@@ -25,6 +33,9 @@ const allArrayItemsAreValid = (allItems, validItems) =>
const allArrayItemsAreValidAxisIds = (array) =>
allArrayItemsAreValid(array, ALL_AXIS_IDS)
+const allArrayItemsAreValidDimensionIds = (array) =>
+ allArrayItemsAreValid(array, validDims)
+
const onlyRulesWithProp = (ruleProp) =>
testResourceRules.filter((rule) => rule[ruleProp])
@@ -49,6 +60,15 @@ const testPropIsArray = (ruleProp) =>
).toBe(true)
})
+const testKeysAreValidDimensionIds = (ruleProp) =>
+ it('keys should be valid dimension ids', () => {
+ expect(
+ onlyRulesWithProp(ruleProp).every((rule) =>
+ allArrayItemsAreValidDimensionIds(Object.keys(rule[ruleProp]))
+ )
+ ).toBe(true)
+ })
+
const testKeysAreValidAxisIds = (ruleProp) =>
it('keys should be valid axis ids', () => {
expect(
@@ -127,6 +147,15 @@ describe("verify each rule's ", () => {
})
})
+ describe('MAX_ITEMS_PER_DIM', () => {
+ const ruleProp = testResourceAllRuleProps['MAX_ITEMS_PER_DIM']
+
+ testPropHasKeysAndValues(ruleProp)
+ testKeysAreValidDimensionIds(ruleProp)
+ testNoValuesZero(ruleProp)
+ testNoValuesNegative(ruleProp)
+ })
+
describe('AVAILABLE_AXES', () => {
const ruleProp = testResourceAllRuleProps['AVAILABLE_AXES']
diff --git a/src/modules/layoutUiRules/index.js b/src/modules/layoutUiRules/index.js
index 3d860c669..74df3a86b 100644
--- a/src/modules/layoutUiRules/index.js
+++ b/src/modules/layoutUiRules/index.js
@@ -6,10 +6,12 @@ export {
getAxisMinNumberOfDimsByVisType as getAxisMinNumberOfDimensions,
getAxisPerLockedDimByVisType as getAxisPerLockedDimension,
getAllLockedDimIdsByVisType as getAllLockedDimensionIds,
+ getDimMaxNumberOfItemsByVisType as getDimensionMaxNumberOfItems,
} from './rulesHelper.js'
export {
hasAxisTooManyItemsByVisType as hasAxisTooManyItems,
+ hasDimensionTooManyItemsByVisType as hasDimensionTooManyItems,
isDimensionLockedByVisType as isDimensionLocked,
isAxisFullByVisType as isAxisFull,
canDimensionBeAddedToAxisByVisType as canDimensionBeAddedToAxis,
diff --git a/src/modules/layoutUiRules/rules.js b/src/modules/layoutUiRules/rules.js
index 8add0bcb2..9c4502b4f 100644
--- a/src/modules/layoutUiRules/rules.js
+++ b/src/modules/layoutUiRules/rules.js
@@ -7,6 +7,7 @@ import {
DIMENSION_ID_PERIOD,
DIMENSION_ID_DATA,
DIMENSION_ID_ORGUNIT,
+ DIMENSION_ID_ASSIGNED_CATEGORIES,
} from '../predefinedDimensions.js'
import {
VIS_TYPE_COLUMN,
@@ -25,12 +26,14 @@ import {
VIS_TYPE_PIVOT_TABLE,
VIS_TYPE_SCATTER,
VIS_TYPE_LINE_LIST,
+ VIS_TYPE_OUTLIER_TABLE,
} from '../visTypes.js'
const RULE_PROP_AVAILABLE_AXES = 'availableAxes',
RULE_PROP_MAX_DIMS_PER_AXIS = 'maxNumberOfDimsPerAxis',
RULE_PROP_MIN_DIMS_PER_AXIS = 'minNumberOfDimsPerAxis',
RULE_PROP_MAX_ITEMS_PER_AXIS = 'maxNumberOfItemsPerAxis',
+ RULE_PROP_MAX_ITEMS_PER_DIM = 'maxNumberOfItemsPerDim',
RULE_PROP_DISALLOWED_DIMS = 'disallowedDims',
RULE_PROP_LOCKED_DIMS = 'lockedDims'
@@ -129,6 +132,22 @@ const lineListRules = {
[RULE_PROP_AVAILABLE_AXES]: [AXIS_ID_COLUMNS, AXIS_ID_FILTERS],
}
+const outlierTableRules = {
+ [RULE_PROP_AVAILABLE_AXES]: [AXIS_ID_COLUMNS],
+ [RULE_PROP_MIN_DIMS_PER_AXIS]: {
+ [AXIS_ID_COLUMNS]: 3,
+ },
+ [RULE_PROP_MAX_ITEMS_PER_DIM]: {
+ [DIMENSION_ID_PERIOD]: 1,
+ },
+ [RULE_PROP_LOCKED_DIMS]: {
+ [DIMENSION_ID_DATA]: AXIS_ID_COLUMNS,
+ [DIMENSION_ID_PERIOD]: AXIS_ID_COLUMNS,
+ [DIMENSION_ID_ORGUNIT]: AXIS_ID_COLUMNS,
+ },
+ [RULE_PROP_DISALLOWED_DIMS]: [DIMENSION_ID_ASSIGNED_CATEGORIES],
+}
+
const visTypeToRules = {
[VIS_TYPE_COLUMN]: defaultRules,
[VIS_TYPE_STACKED_COLUMN]: defaultRules,
@@ -146,6 +165,7 @@ const visTypeToRules = {
[VIS_TYPE_PIVOT_TABLE]: pivotTableRules,
[VIS_TYPE_SCATTER]: scatterRules,
[VIS_TYPE_LINE_LIST]: lineListRules,
+ [VIS_TYPE_OUTLIER_TABLE]: outlierTableRules,
}
const getRulesByVisType = (visType) => {
@@ -169,6 +189,9 @@ export const getMaxNumberOfDimsPerAxisByVisType = (visType) =>
export const getMinNumberOfDimsPerAxisByVisType = (visType) =>
getRulesByVisType(visType)[RULE_PROP_MIN_DIMS_PER_AXIS] || {}
+export const getMaxNumberOfItemsPerDimByVisType = (visType) =>
+ getRulesByVisType(visType)[RULE_PROP_MAX_ITEMS_PER_DIM] || {}
+
export const getMaxNumberOfItemsPerAxisByVisType = (visType) =>
getRulesByVisType(visType)[RULE_PROP_MAX_ITEMS_PER_AXIS] || {}
@@ -189,6 +212,7 @@ export const testResourceAllRuleProps = {
MAX_DIMS_PER_AXIS: RULE_PROP_MAX_DIMS_PER_AXIS,
MIN_DIMS_PER_AXIS: RULE_PROP_MIN_DIMS_PER_AXIS,
MAX_ITEMS_PER_AXIS: RULE_PROP_MAX_ITEMS_PER_AXIS,
+ MAX_ITEMS_PER_DIM: RULE_PROP_MAX_ITEMS_PER_DIM,
DISALLOWED_DIMS: RULE_PROP_DISALLOWED_DIMS,
LOCKED_DIMS: RULE_PROP_LOCKED_DIMS,
}
diff --git a/src/modules/layoutUiRules/rulesHelper.js b/src/modules/layoutUiRules/rulesHelper.js
index d0f85d90e..5d64c19b1 100644
--- a/src/modules/layoutUiRules/rulesHelper.js
+++ b/src/modules/layoutUiRules/rulesHelper.js
@@ -1,4 +1,5 @@
import {
+ getMaxNumberOfItemsPerDimByVisType,
getMaxNumberOfItemsPerAxisByVisType,
getMaxNumberOfDimsPerAxisByVisType,
getMinNumberOfDimsPerAxisByVisType,
@@ -26,3 +27,6 @@ export const getAxisPerLockedDimByVisType = (visType, dimensionId) =>
export const getAllLockedDimIdsByVisType = (visType) =>
Object.keys(getLockedDimsByVisType(visType))
+
+export const getDimMaxNumberOfItemsByVisType = (visType, dimensionId) =>
+ getMaxNumberOfItemsPerDimByVisType(visType)[dimensionId]
diff --git a/src/modules/layoutUiRules/rulesUtils.js b/src/modules/layoutUiRules/rulesUtils.js
index 4f95f2831..f5b858a9a 100644
--- a/src/modules/layoutUiRules/rulesUtils.js
+++ b/src/modules/layoutUiRules/rulesUtils.js
@@ -2,6 +2,7 @@ import { getLockedDimsByVisType } from './rules.js'
import {
getAxisMaxNumberOfDimsByVisType,
getAxisMaxNumberOfItemsByVisType,
+ getDimMaxNumberOfItemsByVisType,
getAllLockedDimIdsByVisType,
} from './rulesHelper.js'
@@ -15,9 +16,23 @@ export const hasAxisTooManyItemsByVisType = (
axisId
)
- return maxNumberOfItemsPerAxis
- ? numberOfItems > maxNumberOfItemsPerAxis
- : false
+ return maxNumberOfItemsPerAxis && numberOfItems > maxNumberOfItemsPerAxis
+}
+
+export const hasDimensionTooManyItemsByVisType = (
+ visType,
+ dimensionId,
+ numberOfItems
+) => {
+ const maxNumberOfItemsPerDimension = getDimMaxNumberOfItemsByVisType(
+ visType,
+ dimensionId
+ )
+
+ return (
+ maxNumberOfItemsPerDimension &&
+ numberOfItems > maxNumberOfItemsPerDimension
+ )
}
export const isDimensionLockedByVisType = (visType, dimensionId) =>
diff --git a/src/modules/pivotTable/PivotTableEngine.js b/src/modules/pivotTable/PivotTableEngine.js
index ddea5c485..ad798e686 100644
--- a/src/modules/pivotTable/PivotTableEngine.js
+++ b/src/modules/pivotTable/PivotTableEngine.js
@@ -1,3 +1,4 @@
+import i18n from '@dhis2/d2-i18n'
import times from 'lodash/times'
import {
DIMENSION_TYPE_DATA,
@@ -7,7 +8,13 @@ import {
} from '../dataTypes.js'
import { DIMENSION_ID_ORGUNIT } from '../predefinedDimensions.js'
import { renderValue } from '../renderValue.js'
-import { VALUE_TYPE_NUMBER, VALUE_TYPE_TEXT } from '../valueTypes.js'
+import {
+ VALUE_TYPE_NUMBER,
+ VALUE_TYPE_TEXT,
+ isBooleanValueType,
+ isCumulativeValueType,
+ isNumericValueType,
+} from '../valueTypes.js'
import { AdaptiveClippingController } from './AdaptiveClippingController.js'
import { addToTotalIfNumber } from './addToTotalIfNumber.js'
import { parseValue } from './parseValue.js'
@@ -35,6 +42,8 @@ import {
NUMBER_TYPE_COLUMN_PERCENTAGE,
NUMBER_TYPE_ROW_PERCENTAGE,
NUMBER_TYPE_VALUE,
+ VALUE_TYPE_NA,
+ VALUE_NA,
} from './pivotTableConstants.js'
const dataFields = [
@@ -54,6 +63,7 @@ const defaultOptions = {
showColumnSubtotals: false,
fixColumnHeaders: false,
fixRowHeaders: false,
+ cumulativeValues: false,
}
const defaultVisualizationProps = {
@@ -238,7 +248,7 @@ const applyTotalAggregationType = (
) => {
switch (overrideTotalAggregationType || totalAggregationType) {
case AGGREGATE_TYPE_NA:
- return 'N/A'
+ return VALUE_NA
case AGGREGATE_TYPE_AVERAGE:
return (
((numerator || value) * multiplier) /
@@ -267,6 +277,7 @@ export class PivotTableEngine {
data = []
rowMap = []
columnMap = []
+ accumulators = { rows: {} }
constructor(visualization, data, legendSets) {
this.visualization = Object.assign(
@@ -305,6 +316,7 @@ export class PivotTableEngine {
fixRowHeaders: this.dimensionLookup.rows.length
? visualization.fixRowHeaders
: false,
+ cumulativeValues: visualization.cumulativeValues,
}
this.adaptiveClippingController = new AdaptiveClippingController(this)
@@ -332,6 +344,7 @@ export class PivotTableEngine {
getRaw({ row, column }) {
const cellType = this.getRawCellType({ row, column })
const dxDimension = this.getRawCellDxDimension({ row, column })
+ const valueType = dxDimension?.valueType || VALUE_TYPE_TEXT
const headers = [
...this.getRawRowHeader(row),
@@ -345,55 +358,106 @@ export class PivotTableEngine {
header?.dimensionItemType === DIMENSION_TYPE_ORGANISATION_UNIT
)?.uid
+ const rawCell = {
+ cellType,
+ valueType,
+ ouId,
+ peId,
+ }
+
if (!this.data[row] || !this.data[row][column]) {
- return {
- cellType,
- empty: true,
- ouId,
- peId,
+ rawCell.empty = true
+ } else {
+ const dataRow = this.data[row][column]
+
+ let rawValue =
+ cellType === CELL_TYPE_VALUE
+ ? dataRow[this.dimensionLookup.dataHeaders.value]
+ : dataRow.value
+ let renderedValue = rawValue
+
+ if (valueType === VALUE_TYPE_NUMBER) {
+ rawValue = parseValue(rawValue)
+ switch (this.visualization.numberType) {
+ case NUMBER_TYPE_ROW_PERCENTAGE:
+ renderedValue =
+ rawValue / this.percentageTotals[row].value
+ break
+ case NUMBER_TYPE_COLUMN_PERCENTAGE:
+ renderedValue =
+ rawValue / this.percentageTotals[column].value
+ break
+ default:
+ break
+ }
}
+
+ renderedValue = renderValue(
+ renderedValue,
+ valueType,
+ this.visualization
+ )
+
+ rawCell.dxDimension = dxDimension
+ rawCell.empty = false
+ rawCell.rawValue = rawValue
+ rawCell.renderedValue = renderedValue
+ }
+
+ if (
+ [CELL_TYPE_TOTAL, CELL_TYPE_SUBTOTAL].includes(rawCell.cellType) &&
+ rawCell.rawValue === AGGREGATE_TYPE_NA
+ ) {
+ rawCell.titleValue = i18n.t('Not applicable')
}
- const dataRow = this.data[row][column]
+ if (this.options.cumulativeValues) {
+ let titleValue
- let rawValue =
- cellType === CELL_TYPE_VALUE
- ? dataRow[this.dimensionLookup.dataHeaders.value]
- : dataRow.value
- let renderedValue = rawValue
- const valueType = dxDimension?.valueType || VALUE_TYPE_TEXT
+ if (this.data[row] && this.data[row][column]) {
+ const dataRow = this.data[row][column]
+
+ const rawValue =
+ cellType === CELL_TYPE_VALUE
+ ? dataRow[this.dimensionLookup.dataHeaders.value]
+ : dataRow.value
- if (valueType === VALUE_TYPE_NUMBER) {
- rawValue = parseValue(rawValue)
- switch (this.visualization.numberType) {
- case NUMBER_TYPE_ROW_PERCENTAGE:
- renderedValue = rawValue / this.percentageTotals[row].value
- break
- case NUMBER_TYPE_COLUMN_PERCENTAGE:
- renderedValue =
- rawValue / this.percentageTotals[column].value
- break
- default:
- break
+ titleValue = i18n.t('Value: {{value}}', {
+ value: renderValue(rawValue, valueType, this.visualization),
+ nsSeparator: '^^',
+ })
}
- }
- renderedValue = renderValue(
- renderedValue,
- valueType,
- this.visualization
- )
+ const cumulativeValue = this.getCumulative({
+ row,
+ column,
+ })
- return {
- cellType,
- empty: false,
- valueType,
- rawValue,
- renderedValue,
- dxDimension,
- ouId,
- peId,
+ if (cumulativeValue !== undefined && cumulativeValue !== null) {
+ // force to TEXT for N/A (accumulated) values
+ // force to NUMBER for accumulated values if no valueType present
+ rawCell.valueType =
+ cumulativeValue === VALUE_NA
+ ? VALUE_TYPE_NA
+ : valueType === undefined || valueType === null
+ ? VALUE_TYPE_NUMBER
+ : valueType
+ rawCell.empty = false
+ rawCell.titleValue = titleValue
+ rawCell.rawValue = cumulativeValue
+ rawCell.renderedValue = renderValue(
+ cumulativeValue,
+ valueType,
+ this.visualization
+ )
+ }
}
+
+ return rawCell
+ }
+
+ getCumulative({ row, column }) {
+ return this.accumulators.rows[row][column]
}
get({ row, column }) {
@@ -448,27 +512,31 @@ export class PivotTableEngine {
columnLevel === lastColumnLevel &&
this.dimensionLookup.columns[lastColumnLevel]
) {
- return `${this.dimensionLookup.rows[lastRowLevel].meta.name} / ${this.dimensionLookup.columns[lastColumnLevel].meta.name}`
+ return `${i18n.t(
+ this.dimensionLookup.rows[lastRowLevel].meta.name
+ )} / ${i18n.t(
+ this.dimensionLookup.columns[lastColumnLevel].meta.name
+ )}`
}
if (lastRowLevel === -1) {
- return this.dimensionLookup.columns[columnLevel].meta.name
+ return i18n.t(this.dimensionLookup.columns[columnLevel].meta.name)
}
if (lastColumnLevel === -1) {
- return this.dimensionLookup.rows[rowLevel].meta.name
+ return i18n.t(this.dimensionLookup.rows[rowLevel].meta.name)
}
if (
rowLevel === lastRowLevel &&
this.dimensionLookup.columns[columnLevel]
) {
- return this.dimensionLookup.columns[columnLevel].meta.name
+ return i18n.t(this.dimensionLookup.columns[columnLevel].meta.name)
}
if (
columnLevel === lastColumnLevel &&
this.dimensionLookup.rows[rowLevel]
) {
- return this.dimensionLookup.rows[rowLevel].meta.name
+ return i18n.t(this.dimensionLookup.rows[rowLevel].meta.name)
}
}
@@ -482,11 +550,15 @@ export class PivotTableEngine {
if (!this.data[row]) {
return undefined
}
+
const cellValue = this.data[row][column]
+
+ // empty cell
if (!cellValue) {
return undefined
}
- if (!Array.isArray(cellValue)) {
+
+ if (cellValue && !Array.isArray(cellValue)) {
// This is a total cell
return {
valueType: cellValue.valueType,
@@ -501,6 +573,7 @@ export class PivotTableEngine {
const dxRowIndex = this.dimensionLookup.rows.findIndex(
(dim) => dim.isDxDimension
)
+
if (rowHeaders.length && dxRowIndex !== -1) {
return {
valueType: rowHeaders[dxRowIndex].valueType,
@@ -534,7 +607,7 @@ export class PivotTableEngine {
return !this.data[row] || this.data[row].length === 0
}
columnIsEmpty(column) {
- return !this.adaptiveClippingController.columns.sizes[column]
+ return !this.rowMap.some((row) => this.data[row][column])
}
getRawColumnHeader(column) {
@@ -694,15 +767,30 @@ export class PivotTableEngine {
totalCell.totalAggregationType = currentAggType
}
- const currentValueType = dxDimension?.valueType
+ // Force value type of total cells to NUMBER for value cells with numeric or boolean types.
+ // This is to simplify the code below where we compare the previous value type.
+ // All numeric/boolean value types use the same style for rendering the total cell (right aligned content)
+ // and using NUMBER for the total cell is enough for that.
+ // (see DHIS2-9155)
+ const currentValueType =
+ isNumericValueType(dxDimension?.valueType) ||
+ isBooleanValueType(dxDimension?.valueType)
+ ? VALUE_TYPE_NUMBER
+ : dxDimension?.valueType
+
const previousValueType = totalCell.valueType
if (previousValueType && currentValueType !== previousValueType) {
- totalCell.valueType = AGGREGATE_TYPE_NA
+ totalCell.valueType = VALUE_TYPE_NA
} else {
totalCell.valueType = currentValueType
}
- if (dxDimension?.valueType === VALUE_TYPE_NUMBER) {
+ // Compute totals for all numeric and boolean value types only.
+ // In practice valueType here is NUMBER (see the comment above).
+ // When is not, it means there is some value cell with a valueType other than numeric/boolean,
+ // the total should not be computed then.
+ // (see DHIS2-9155)
+ if (isNumericValueType(totalCell.valueType)) {
dataFields.forEach((field) => {
const headerIndex = this.dimensionLookup.dataHeaders[field]
const value = parseValue(dataRow[headerIndex])
@@ -827,6 +915,28 @@ export class PivotTableEngine {
}
}
}
+
+ computeOverrideTotalAggregationType(totalCell, visualization) {
+ // Avoid undefined on total cells with valueTypes that cannot be totalized.
+ // This happens for example when a column/row has all value cells of type TEXT.
+ if (
+ !(
+ isNumericValueType(totalCell.valueType) ||
+ isBooleanValueType(totalCell.valueType)
+ )
+ ) {
+ return AGGREGATE_TYPE_NA
+ }
+
+ // DHIS2-15698: do not override total aggregation type when numberType option is not present
+ // (numberType option default is VALUE)
+ return (
+ visualization.numberType &&
+ visualization.numberType !== NUMBER_TYPE_VALUE &&
+ AGGREGATE_TYPE_SUM
+ )
+ }
+
finalizeTotal({ row, column }) {
if (!this.data[row]) {
return
@@ -835,12 +945,17 @@ export class PivotTableEngine {
if (totalCell && totalCell.count) {
totalCell.value = applyTotalAggregationType(
totalCell,
- // DHIS2-15698: do not override total aggregation type when numberType option is not present
- // (numberType option default is VALUE)
- this.visualization.numberType &&
- this.visualization.numberType !== NUMBER_TYPE_VALUE &&
- AGGREGATE_TYPE_SUM
+ this.computeOverrideTotalAggregationType(
+ totalCell,
+ this.visualization
+ )
)
+
+ // override valueType for styling cells with N/A value
+ if (totalCell.value === AGGREGATE_TYPE_NA) {
+ totalCell.valueType = VALUE_TYPE_NA
+ }
+
this.adaptiveClippingController.add(
{ row, column },
renderValue(
@@ -962,6 +1077,54 @@ export class PivotTableEngine {
: times(this.dataWidth, (n) => n)
}
+ resetAccumulators() {
+ if (this.options.cumulativeValues) {
+ this.rowMap.forEach((row) => {
+ this.accumulators.rows[row] = {}
+ this.columnMap.reduce((acc, column) => {
+ const cellType = this.getRawCellType({ row, column })
+ const dxDimension = this.getRawCellDxDimension({
+ row,
+ column,
+ })
+ const valueType = dxDimension?.valueType || VALUE_TYPE_TEXT
+ const totalAggregationType =
+ dxDimension?.totalAggregationType
+
+ // only accumulate numeric (except for PERCENTAGE and UNIT_INTERVAL) and boolean values
+ // accumulating other value types like text values does not make sense
+ if (
+ isCumulativeValueType(valueType) &&
+ totalAggregationType === AGGREGATE_TYPE_SUM
+ ) {
+ // initialise to 0 for cumulative types
+ // (||= is not transformed correctly in Babel with the current setup)
+ acc || (acc = 0)
+
+ if (this.data[row] && this.data[row][column]) {
+ const dataRow = this.data[row][column]
+
+ const rawValue =
+ cellType === CELL_TYPE_VALUE
+ ? dataRow[
+ this.dimensionLookup.dataHeaders.value
+ ]
+ : dataRow.value
+
+ acc += parseValue(rawValue)
+ }
+
+ this.accumulators.rows[row][column] = acc
+ }
+
+ return acc
+ }, '')
+ })
+ } else {
+ this.accumulators = { rows: {} }
+ }
+ }
+
get cellPadding() {
switch (this.visualization.displayDensity) {
case DISPLAY_DENSITY_OPTION_COMPACT:
@@ -1054,19 +1217,22 @@ export class PivotTableEngine {
this.finalizeTotals()
- this.rawData.rows.forEach((dataRow) => {
- const pos = lookup(dataRow, this.dimensionLookup, this)
- if (pos) {
+ this.resetRowMap()
+ this.resetColumnMap()
+
+ this.resetAccumulators()
+
+ this.rowMap.forEach((row) => {
+ this.columnMap.forEach((column) => {
+ const pos = { row, column }
+
this.adaptiveClippingController.add(
pos,
this.getRaw(pos).renderedValue
)
- }
+ })
})
- this.resetRowMap()
- this.resetColumnMap()
-
this.height = this.rowMap.length
this.width = this.columnMap.length
diff --git a/src/modules/pivotTable/__tests__/addToTotalIfNumber.js b/src/modules/pivotTable/__tests__/addToTotalIfNumber.spec.js
similarity index 100%
rename from src/modules/pivotTable/__tests__/addToTotalIfNumber.js
rename to src/modules/pivotTable/__tests__/addToTotalIfNumber.spec.js
diff --git a/src/modules/pivotTable/pivotTableConstants.js b/src/modules/pivotTable/pivotTableConstants.js
index 1221972c9..1ab1b290d 100644
--- a/src/modules/pivotTable/pivotTableConstants.js
+++ b/src/modules/pivotTable/pivotTableConstants.js
@@ -9,6 +9,8 @@ export const AGGREGATE_TYPE_SUM = 'SUM'
export const AGGREGATE_TYPE_AVERAGE = 'AVERAGE'
export const AGGREGATE_TYPE_NA = 'N/A'
+export const VALUE_TYPE_NA = 'N_A' // this ends up as CSS class and / is problematic
+
export const NUMBER_TYPE_VALUE = 'VALUE'
export const NUMBER_TYPE_ROW_PERCENTAGE = 'ROW_PERCENTAGE'
export const NUMBER_TYPE_COLUMN_PERCENTAGE = 'COLUMN_PERCENTAGE'
@@ -35,3 +37,5 @@ export const WRAPPED_TEXT_JUSTIFY_BUFFER = 25
export const WRAPPED_TEXT_LINE_HEIGHT = 1.0
export const CLIPPED_AXIS_PARTITION_SIZE_PX = 1000
+
+export const VALUE_NA = 'N/A'
diff --git a/src/modules/renderValue.js b/src/modules/renderValue.js
index 17e288043..9c2f1c763 100644
--- a/src/modules/renderValue.js
+++ b/src/modules/renderValue.js
@@ -2,13 +2,11 @@ import {
NUMBER_TYPE_ROW_PERCENTAGE,
NUMBER_TYPE_COLUMN_PERCENTAGE,
} from './pivotTable/pivotTableConstants.js'
-import { isNumericValueType } from './valueTypes.js'
+import { isNumericValueType, isBooleanValueType } from './valueTypes.js'
const trimTrailingZeros = (stringValue) => stringValue.replace(/\.?0+$/, '')
-const decimalSeparator = '.'
-
-const separateDigitGroups = (stringValue, decimalSeparator) => {
+export const separateDigitGroups = (stringValue, decimalSeparator = '.') => {
const isNegative = stringValue[0] === '-'
const [integer, remainder] = stringValue.replace(/^-/, '').split('.')
@@ -49,13 +47,16 @@ const toFixedPrecisionString = (value, skipRounding) => {
return value
}
- const precision = skipRounding ? 10 : value > -1 && value < 1 ? 2 : 1
+ const precision = skipRounding ? 10 : 2
return value.toFixed(precision)
}
export const renderValue = (value, valueType, visualization) => {
- if (!isNumericValueType(valueType) || value === undefined) {
+ if (
+ !(isNumericValueType(valueType) || isBooleanValueType(valueType)) ||
+ value === undefined
+ ) {
return String(value).replace(/[^\S\n]+/, ' ')
}
@@ -68,9 +69,8 @@ export const renderValue = (value, valueType, visualization) => {
)
return (
- separateDigitGroups(stringValue, decimalSeparator).join(
- getSeparator(visualization)
- ) + '%'
+ separateDigitGroups(stringValue).join(getSeparator(visualization)) +
+ '%'
)
} else {
const stringValue = toFixedPrecisionString(
@@ -78,7 +78,7 @@ export const renderValue = (value, valueType, visualization) => {
visualization.skipRounding
)
- return separateDigitGroups(stringValue, decimalSeparator).join(
+ return separateDigitGroups(stringValue).join(
getSeparator(visualization)
)
}
diff --git a/src/modules/valueTypes.js b/src/modules/valueTypes.js
index cf5dc01f0..1097ac84f 100644
--- a/src/modules/valueTypes.js
+++ b/src/modules/valueTypes.js
@@ -34,4 +34,18 @@ const NUMERIC_VALUE_TYPES = [
VALUE_TYPE_INTEGER_ZERO_OR_POSITIVE,
]
+const BOOLEAN_VALUE_TYPES = [VALUE_TYPE_BOOLEAN, VALUE_TYPE_TRUE_ONLY]
+
+const CUMULATIVE_VALUE_TYPES = [
+ VALUE_TYPE_NUMBER,
+ VALUE_TYPE_INTEGER,
+ VALUE_TYPE_INTEGER_POSITIVE,
+ VALUE_TYPE_INTEGER_NEGATIVE,
+ VALUE_TYPE_INTEGER_ZERO_OR_POSITIVE,
+ ...BOOLEAN_VALUE_TYPES,
+]
+
+export const isCumulativeValueType = (type) =>
+ CUMULATIVE_VALUE_TYPES.includes(type)
export const isNumericValueType = (type) => NUMERIC_VALUE_TYPES.includes(type)
+export const isBooleanValueType = (type) => BOOLEAN_VALUE_TYPES.includes(type)
diff --git a/src/modules/visTypeToLayoutType.js b/src/modules/visTypeToLayoutType.js
index 0b1d21af1..cd1d94b69 100644
--- a/src/modules/visTypeToLayoutType.js
+++ b/src/modules/visTypeToLayoutType.js
@@ -5,6 +5,7 @@ import {
LAYOUT_TYPE_PIVOT_TABLE,
LAYOUT_TYPE_SCATTER,
LAYOUT_TYPE_LINE_LIST,
+ LAYOUT_TYPE_OUTLIER_TABLE,
} from './layoutTypes.js'
import {
VIS_TYPE_COLUMN,
@@ -23,6 +24,7 @@ import {
VIS_TYPE_PIVOT_TABLE,
VIS_TYPE_SCATTER,
VIS_TYPE_LINE_LIST,
+ VIS_TYPE_OUTLIER_TABLE,
} from './visTypes.js'
const visTypeToLayoutType = {
@@ -42,6 +44,7 @@ const visTypeToLayoutType = {
[VIS_TYPE_PIVOT_TABLE]: LAYOUT_TYPE_PIVOT_TABLE,
[VIS_TYPE_SCATTER]: LAYOUT_TYPE_SCATTER,
[VIS_TYPE_LINE_LIST]: LAYOUT_TYPE_LINE_LIST,
+ [VIS_TYPE_OUTLIER_TABLE]: LAYOUT_TYPE_OUTLIER_TABLE,
}
export const getLayoutTypeByVisType = (visType) => visTypeToLayoutType[visType]
diff --git a/src/modules/visTypes.js b/src/modules/visTypes.js
index 8150b43f2..bd7a9b37a 100644
--- a/src/modules/visTypes.js
+++ b/src/modules/visTypes.js
@@ -1,5 +1,4 @@
import {
- IconTable24,
IconVisualizationArea24,
IconVisualizationAreaStacked24,
IconVisualizationBar24,
@@ -11,7 +10,9 @@ import {
IconVisualizationLine24,
IconVisualizationLinelist24,
IconVisualizationLineMulti24,
+ IconVisualizationOutlierTable24,
IconVisualizationPie24,
+ IconVisualizationPivotTable24,
IconVisualizationRadar24,
IconVisualizationScatter24,
IconVisualizationSingleValue24,
@@ -36,6 +37,7 @@ export const VIS_TYPE_SINGLE_VALUE = 'SINGLE_VALUE'
export const VIS_TYPE_BUBBLE = 'BUBBLE'
export const VIS_TYPE_GROUP_ALL = 'ALL'
export const VIS_TYPE_GROUP_CHARTS = 'CHARTS'
+export const VIS_TYPE_OUTLIER_TABLE = 'OUTLIER_TABLE'
export const visTypeDisplayNames = {
[VIS_TYPE_PIVOT_TABLE]: i18n.t('Pivot table'),
@@ -54,12 +56,13 @@ export const visTypeDisplayNames = {
[VIS_TYPE_RADAR]: i18n.t('Radar'),
[VIS_TYPE_SCATTER]: i18n.t('Scatter'),
[VIS_TYPE_SINGLE_VALUE]: i18n.t('Single value'),
+ [VIS_TYPE_OUTLIER_TABLE]: i18n.t('Outlier table'),
[VIS_TYPE_GROUP_ALL]: i18n.t('All types'),
[VIS_TYPE_GROUP_CHARTS]: i18n.t('All charts'),
}
export const visTypeIcons = {
- [VIS_TYPE_PIVOT_TABLE]: IconTable24,
+ [VIS_TYPE_PIVOT_TABLE]: IconVisualizationPivotTable24,
[VIS_TYPE_AREA]: IconVisualizationArea24,
[VIS_TYPE_STACKED_AREA]: IconVisualizationAreaStacked24,
[VIS_TYPE_BAR]: IconVisualizationBar24,
@@ -75,6 +78,7 @@ export const visTypeIcons = {
[VIS_TYPE_RADAR]: IconVisualizationRadar24,
[VIS_TYPE_SCATTER]: IconVisualizationScatter24,
[VIS_TYPE_SINGLE_VALUE]: IconVisualizationSingleValue24,
+ [VIS_TYPE_OUTLIER_TABLE]: IconVisualizationOutlierTable24,
}
export const getDisplayNameByVisType = (visType) => {
@@ -137,12 +141,13 @@ const legendSetTypes = [
VIS_TYPE_STACKED_BAR,
]
-export const defaultVisType = VIS_TYPE_COLUMN
+export const defaultVisType = VIS_TYPE_PIVOT_TABLE
export const isStacked = (type) => stackedTypes.includes(type)
export const isYearOverYear = (type) => yearOverYearTypes.includes(type)
export const isDualAxisType = (type) => dualAxisTypes.includes(type)
export const isMultiType = (type) => multiTypeTypes.includes(type)
export const isSingleValue = (type) => type === VIS_TYPE_SINGLE_VALUE
+export const isOutlierTable = (type) => type === VIS_TYPE_OUTLIER_TABLE
export const isTwoCategoryChartType = (type) =>
twoCategoryChartTypes.includes(type)
export const isVerticalType = (type) => verticalTypes.includes(type)
diff --git a/src/visualizations/config/adapters/dhis_dhis/index.js b/src/visualizations/config/adapters/dhis_dhis/index.js
deleted file mode 100644
index 06a5256bf..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/index.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import getSubtitle from './subtitle/index.js'
-import getTitle from './title/index.js'
-import getValue from './value/index.js'
-
-export const INDICATOR_FACTOR_100 = 100
-
-export default function ({ store, layout, extraOptions }) {
- const data = store.generateData({
- type: layout.type,
- seriesId:
- layout.columns && layout.columns.length
- ? layout.columns[0].dimension
- : null,
- categoryId:
- layout.rows && layout.rows.length ? layout.rows[0].dimension : null,
- })
- const metaData = store.data[0].metaData
-
- const config = {
- value: data[0],
- formattedValue:
- data[0] === undefined
- ? extraOptions.noData.text
- : getValue(data[0], layout, metaData),
- title: getTitle(layout, metaData, extraOptions.dashboard),
- subtitle: getSubtitle(layout, metaData, extraOptions.dashboard),
- }
-
- const indicatorType =
- metaData.items[metaData.dimensions.dx[0]].indicatorType
-
- // Use % symbol for factor 100 and the full string for others
- if (indicatorType?.factor !== INDICATOR_FACTOR_100) {
- config.subText = indicatorType?.displayName
- }
-
- return config
-}
diff --git a/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/index.spec.js b/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/index.spec.js
deleted file mode 100644
index 486333c8c..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/index.spec.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../../../modules/visTypes.js'
-import getSubtitle from '../index.js'
-
-jest.mock('../singleValue', () => () => 'The sv filter title')
-jest.mock(
- '../../../../../util/getFilterText',
- () => () => 'The default filter text'
-)
-
-describe('getSubtitle', () => {
- it('returns empty subtitle when flag hideSubtitle exists', () => {
- expect(getSubtitle({ hideSubtitle: true })).toEqual('')
- })
-
- it('returns the subtitle provided in the layout', () => {
- const subtitle = 'The subtitle was already set'
- expect(getSubtitle({ subtitle })).toEqual(subtitle)
- })
-
- it('returns subtitle for single value vis', () => {
- expect(getSubtitle({ type: VIS_TYPE_SINGLE_VALUE })).toEqual(
- 'The sv filter title'
- )
- })
-
- describe('not dashboard', () => {
- describe('layout does not include title', () => {
- it('returns empty subtitle', () => {
- expect(getSubtitle({ filters: {} }, {}, false)).toEqual('')
- })
- })
-
- describe('layout includes title', () => {
- it('returns filter title as subtitle', () => {
- expect(
- getSubtitle(
- { filters: {}, title: 'Chart title' },
- {},
- false
- )
- ).toEqual('The default filter text')
- })
- })
- })
-
- describe('dashboard', () => {
- it('returns filter title as subtitle', () => {
- expect(getSubtitle({ filters: {} }, {}, true)).toEqual(
- 'The default filter text'
- )
- })
- })
-})
diff --git a/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/singleValue.spec.js b/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/singleValue.spec.js
deleted file mode 100644
index 39b497f64..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/singleValue.spec.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import getSingleValueSubtitle from '../singleValue.js'
-
-jest.mock('../../../../../util/getFilterText', () => () => 'The filter text')
-
-describe('getSingleValueSubtitle', () => {
- it('returns null when layout does not have filters', () => {
- expect(getSingleValueSubtitle({})).toEqual('')
- })
-
- it('returns the filter text', () => {
- expect(getSingleValueSubtitle({ filters: [] })).toEqual(
- 'The filter text'
- )
- })
-})
diff --git a/src/visualizations/config/adapters/dhis_dhis/subtitle/index.js b/src/visualizations/config/adapters/dhis_dhis/subtitle/index.js
deleted file mode 100644
index 1be507be4..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/subtitle/index.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../../modules/visTypes.js'
-import getFilterText from '../../../../util/getFilterText.js'
-import getSingleValueTitle from './singleValue.js'
-
-function getDefault(layout, dashboard, metaData) {
- if (dashboard || typeof layout.title === 'string') {
- return getFilterText(layout.filters, metaData)
- }
-
- return ''
-}
-
-export default function (layout, metaData, dashboard) {
- if (layout.hideSubtitle) {
- return ''
- }
-
- if (typeof layout.subtitle === 'string' && layout.subtitle.length) {
- return layout.subtitle
- } else {
- let subtitle
- switch (layout.type) {
- case VIS_TYPE_SINGLE_VALUE:
- subtitle = getSingleValueTitle(layout, metaData)
-
- break
- default:
- subtitle = getDefault(layout, dashboard, metaData)
- }
-
- return subtitle
- }
-}
diff --git a/src/visualizations/config/adapters/dhis_dhis/subtitle/singleValue.js b/src/visualizations/config/adapters/dhis_dhis/subtitle/singleValue.js
deleted file mode 100644
index de246ba2f..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/subtitle/singleValue.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import getFilterText from '../../../../util/getFilterText.js'
-
-export default function (layout, metaData) {
- return layout.filters ? getFilterText(layout.filters, metaData) : ''
-}
diff --git a/src/visualizations/config/adapters/dhis_dhis/title/__tests__/index.spec.js b/src/visualizations/config/adapters/dhis_dhis/title/__tests__/index.spec.js
deleted file mode 100644
index 15a4b8a56..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/title/__tests__/index.spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../../../modules/visTypes.js'
-import getTitle from '../index.js'
-
-jest.mock('../singleValue', () => () => 'The sv filter title')
-jest.mock('../../../../../util/getFilterText', () => () => 'The filter text')
-
-describe('getTitle', () => {
- it('returns empty title when flag hideTitle exists', () => {
- expect(getTitle({ hideTitle: true })).toEqual('')
- })
-
- it('returns the title provided in the layout', () => {
- const title = 'The title was already set'
- expect(getTitle({ title })).toEqual(title)
- })
-
- it('returns title for single value vis', () => {
- expect(getTitle({ type: VIS_TYPE_SINGLE_VALUE })).toEqual(
- 'The sv filter title'
- )
- })
-
- describe('not dashboard', () => {
- it('returns filter text as title', () => {
- expect(getTitle({ filters: {} }, {}, false)).toEqual(
- 'The filter text'
- )
- })
- })
-
- describe('dashboard', () => {
- it('returns empty string', () => {
- expect(getTitle({ filters: {} }, {}, true)).toEqual('')
- })
- })
-})
diff --git a/src/visualizations/config/adapters/dhis_dhis/title/__tests__/singleValue.spec.js b/src/visualizations/config/adapters/dhis_dhis/title/__tests__/singleValue.spec.js
deleted file mode 100644
index 304be7bdb..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/title/__tests__/singleValue.spec.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import getSingleValueTitle from '../singleValue.js'
-
-jest.mock('../../../../../util/getFilterText', () => () => 'The filter text')
-
-describe('getSingleValueTitle', () => {
- it('returns null when layout does not have columns', () => {
- expect(getSingleValueTitle({})).toEqual('')
- })
-
- it('returns the filter text based on column items', () => {
- expect(
- getSingleValueTitle({
- columns: [
- {
- items: [{}],
- },
- ],
- })
- ).toEqual('The filter text')
- })
-})
diff --git a/src/visualizations/config/adapters/dhis_dhis/title/index.js b/src/visualizations/config/adapters/dhis_dhis/title/index.js
deleted file mode 100644
index fb4c6b040..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/title/index.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../../modules/visTypes.js'
-import getFilterText from '../../../../util/getFilterText.js'
-import getSingleValueTitle from './singleValue.js'
-
-function getDefault(layout, metaData, dashboard) {
- return layout.filters && !dashboard
- ? getFilterText(layout.filters, metaData)
- : ''
-}
-
-export default function (layout, metaData, dashboard) {
- if (layout.hideTitle) {
- return ''
- }
-
- if (typeof layout.title === 'string' && layout.title.length) {
- return layout.title
- } else {
- let title
- switch (layout.type) {
- case VIS_TYPE_SINGLE_VALUE:
- title = getSingleValueTitle(layout, metaData)
-
- break
- default:
- title = getDefault(layout, metaData, dashboard)
- }
- return title
- }
-}
diff --git a/src/visualizations/config/adapters/dhis_dhis/type.js b/src/visualizations/config/adapters/dhis_dhis/type.js
deleted file mode 100644
index 412124e58..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/type.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
-
-export default function (type) {
- switch (type) {
- case VIS_TYPE_SINGLE_VALUE:
- return { type: VIS_TYPE_SINGLE_VALUE }
- default:
- return { type: VIS_TYPE_SINGLE_VALUE }
- }
-}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/chart.js b/src/visualizations/config/adapters/dhis_highcharts/chart.js
deleted file mode 100644
index 9174ad816..000000000
--- a/src/visualizations/config/adapters/dhis_highcharts/chart.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import getType from './type.js'
-
-const DEFAULT_CHART = {
- spacingTop: 20,
- style: {
- fontFamily: 'Roboto,Helvetica Neue,Helvetica,Arial,sans-serif',
- },
-}
-
-const DASHBOARD_CHART = {
- spacingTop: 0,
- spacingRight: 5,
- spacingBottom: 2,
- spacingLeft: 5,
-}
-
-const getEvents = () => ({
- events: {
- load: function () {
- // Align legend icon with legend text
- this.legend.allItems.forEach((item) => {
- if (item.legendSymbol) {
- item.legendSymbol.attr({
- translateY:
- -((item.legendItem.getBBox().height * 0.75) / 4) +
- item.legendSymbol.r / 2,
- })
- }
- })
- },
- },
-})
-
-export default function (layout, el, dashboard) {
- return Object.assign(
- {},
- getType(layout.type),
- { renderTo: el || layout.el },
- DEFAULT_CHART,
- dashboard ? DASHBOARD_CHART : undefined,
- getEvents()
- )
-}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/chart/default.js b/src/visualizations/config/adapters/dhis_highcharts/chart/default.js
new file mode 100644
index 000000000..9d4af9829
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/chart/default.js
@@ -0,0 +1,27 @@
+import { getEvents } from '../events/index.js'
+import getType from '../type.js'
+
+const DEFAULT_CHART = {
+ spacingTop: 20,
+ style: {
+ fontFamily: 'Roboto,Helvetica Neue,Helvetica,Arial,sans-serif',
+ },
+}
+
+const DASHBOARD_CHART = {
+ spacingTop: 0,
+ spacingRight: 5,
+ spacingBottom: 2,
+ spacingLeft: 5,
+}
+
+export default function getDefaultChart(layout, el, extraOptions) {
+ return Object.assign(
+ {},
+ getType(layout.type),
+ { renderTo: el || layout.el },
+ DEFAULT_CHART,
+ extraOptions.dashboard ? DASHBOARD_CHART : undefined,
+ getEvents(layout.type)
+ )
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/chart/index.js b/src/visualizations/config/adapters/dhis_highcharts/chart/index.js
new file mode 100644
index 000000000..c6010e016
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/chart/index.js
@@ -0,0 +1,12 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../../modules/visTypes.js'
+import getDefaultChart from './default.js'
+import getSingleValueChart from './singleValue.js'
+
+export default function getChart(layout, el, extraOptions, series) {
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ return getSingleValueChart(layout, el, extraOptions, series)
+ default:
+ return getDefaultChart(layout, el, extraOptions)
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/chart/singleValue.js b/src/visualizations/config/adapters/dhis_highcharts/chart/singleValue.js
new file mode 100644
index 000000000..43a6f66a2
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/chart/singleValue.js
@@ -0,0 +1,19 @@
+import { getSingleValueBackgroundColor } from '../customSVGOptions/singleValue/getSingleValueBackgroundColor.js'
+import getDefaultChart from './default.js'
+
+export default function getSingleValueChart(layout, el, extraOptions, series) {
+ const chart = {
+ ...getDefaultChart(layout, el, extraOptions),
+ backgroundColor: getSingleValueBackgroundColor(
+ layout.legend,
+ extraOptions.legendSets,
+ series[0]
+ ),
+ }
+
+ if (extraOptions.dashboard) {
+ chart.spacingTop = 7
+ }
+
+ return chart
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/index.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/index.js
new file mode 100644
index 000000000..ef5b18509
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/index.js
@@ -0,0 +1,29 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../../modules/visTypes.js'
+import getSingleValueCustomSVGOptions from './singleValue/index.js'
+
+export default function getCustomSVGOptions({
+ extraConfig,
+ layout,
+ extraOptions,
+ metaData,
+ series,
+}) {
+ const baseOptions = {
+ visualizationType: layout.type,
+ }
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ return {
+ ...baseOptions,
+ ...getSingleValueCustomSVGOptions({
+ extraConfig,
+ layout,
+ extraOptions,
+ metaData,
+ series,
+ }),
+ }
+ default:
+ break
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueBackgroundColor.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueBackgroundColor.js
new file mode 100644
index 000000000..650c895a5
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueBackgroundColor.js
@@ -0,0 +1,17 @@
+import { LEGEND_DISPLAY_STYLE_FILL } from '../../../../../../modules/legends.js'
+import { getSingleValueLegendColor } from './getSingleValueLegendColor.js'
+
+export function getSingleValueBackgroundColor(
+ legendOptions,
+ legendSets,
+ value
+) {
+ const legendColor = getSingleValueLegendColor(
+ legendOptions,
+ legendSets,
+ value
+ )
+ return legendColor && legendOptions.style === LEGEND_DISPLAY_STYLE_FILL
+ ? legendColor
+ : 'transparent'
+}
diff --git a/src/visualizations/config/adapters/dhis_dhis/value/index.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js
similarity index 69%
rename from src/visualizations/config/adapters/dhis_dhis/value/index.js
rename to src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js
index 508f1c9a4..f0b91dee3 100644
--- a/src/visualizations/config/adapters/dhis_dhis/value/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js
@@ -1,8 +1,9 @@
-import { renderValue } from '../../../../../modules/renderValue.js'
-import { VALUE_TYPE_TEXT } from '../../../../../modules/valueTypes.js'
-import { INDICATOR_FACTOR_100 } from '../index.js'
+import { renderValue } from '../../../../../../modules/renderValue.js'
+import { VALUE_TYPE_TEXT } from '../../../../../../modules/valueTypes.js'
-export default function (value, layout, metaData) {
+export const INDICATOR_FACTOR_100 = 100
+
+export function getSingleValueFormattedValue(value, layout, metaData) {
const valueType = metaData.items[metaData.dimensions.dx[0]].valueType
const indicatorType =
metaData.items[metaData.dimensions.dx[0]].indicatorType
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueLegendColor.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueLegendColor.js
new file mode 100644
index 000000000..9f042fc4d
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueLegendColor.js
@@ -0,0 +1,8 @@
+import { getColorByValueFromLegendSet } from '../../../../../../modules/legends.js'
+
+export function getSingleValueLegendColor(legendOptions, legendSets, value) {
+ const legendSet = legendOptions && legendSets[0]
+ return legendSet
+ ? getColorByValueFromLegendSet(legendSet, value)
+ : undefined
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueSubtext.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueSubtext.js
new file mode 100644
index 000000000..b14a3f263
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueSubtext.js
@@ -0,0 +1,11 @@
+import { INDICATOR_FACTOR_100 } from './getSingleValueFormattedValue.js'
+
+export function getSingleValueSubtext(metaData) {
+ const indicatorType =
+ metaData.items[metaData.dimensions.dx[0]].indicatorType
+
+ return indicatorType?.displayName &&
+ indicatorType?.factor !== INDICATOR_FACTOR_100
+ ? indicatorType?.displayName
+ : undefined
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTextColor.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTextColor.js
new file mode 100644
index 000000000..2f3eb0da0
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTextColor.js
@@ -0,0 +1,27 @@
+import { colors } from '@dhis2/ui'
+import { LEGEND_DISPLAY_STYLE_TEXT } from '../../../../../../modules/legends.js'
+import { shouldUseContrastColor } from '../../../../../util/shouldUseContrastColor.js'
+import { getSingleValueLegendColor } from './getSingleValueLegendColor.js'
+
+export function getSingleValueTextColor(
+ baseColor,
+ value,
+ legendOptions,
+ legendSets
+) {
+ const legendColor = getSingleValueLegendColor(
+ legendOptions,
+ legendSets,
+ value
+ )
+
+ if (!legendColor) {
+ return baseColor
+ }
+
+ if (legendOptions.style === LEGEND_DISPLAY_STYLE_TEXT) {
+ return legendColor
+ }
+
+ return shouldUseContrastColor(legendColor) ? colors.white : baseColor
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTitleColor.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTitleColor.js
new file mode 100644
index 000000000..bf4f0672b
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTitleColor.js
@@ -0,0 +1,34 @@
+import { colors } from '@dhis2/ui'
+import { LEGEND_DISPLAY_STYLE_FILL } from '../../../../../../modules/legends.js'
+import { shouldUseContrastColor } from '../../../../../util/shouldUseContrastColor.js'
+import { getSingleValueLegendColor } from './getSingleValueLegendColor.js'
+
+export function getSingleValueTitleColor(
+ customColor,
+ defaultColor,
+ value,
+ legendOptions,
+ legendSets
+) {
+ // Never override custom color
+ if (customColor) {
+ return customColor
+ }
+
+ const isUsingLegendBackground =
+ legendOptions?.style === LEGEND_DISPLAY_STYLE_FILL
+
+ // If not using legend background, always return default color
+ if (!isUsingLegendBackground) {
+ return defaultColor
+ }
+
+ const legendColor = getSingleValueLegendColor(
+ legendOptions,
+ legendSets,
+ value
+ )
+
+ // Return default color or contrasting color when using legend background and default color
+ return shouldUseContrastColor(legendColor) ? colors.white : defaultColor
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/index.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/index.js
new file mode 100644
index 000000000..bb0ff56f1
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/index.js
@@ -0,0 +1,27 @@
+import { colors } from '@dhis2/ui'
+import { getSingleValueFormattedValue } from './getSingleValueFormattedValue.js'
+import { getSingleValueSubtext } from './getSingleValueSubtext.js'
+import { getSingleValueTextColor } from './getSingleValueTextColor.js'
+
+export default function getSingleValueCustomSVGOptions({
+ layout,
+ extraOptions,
+ metaData,
+ series,
+}) {
+ const { dashboard, icon } = extraOptions
+ const value = series[0]
+ return {
+ value,
+ fontColor: getSingleValueTextColor(
+ colors.grey900,
+ value,
+ layout.legend,
+ extraOptions.legendSets
+ ),
+ formattedValue: getSingleValueFormattedValue(value, layout, metaData),
+ icon,
+ dashboard,
+ subText: getSingleValueSubtext(metaData),
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/index.js b/src/visualizations/config/adapters/dhis_highcharts/events/index.js
new file mode 100644
index 000000000..4f8bf0904
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/index.js
@@ -0,0 +1,23 @@
+import loadCustomSVG from './loadCustomSVG/index.js'
+
+export const getEvents = (visType) => ({
+ events: {
+ load: function () {
+ // Align legend icon with legend text
+ this.legend.allItems.forEach((item) => {
+ if (item.legendSymbol) {
+ item.legendSymbol.attr({
+ translateY:
+ -(
+ (item.legendItem.label.getBBox().height *
+ 0.75) /
+ 4
+ ) +
+ item.legendSymbol.r / 2,
+ })
+ }
+ })
+ loadCustomSVG.call(this, visType)
+ },
+ },
+})
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/index.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/index.js
new file mode 100644
index 000000000..6e01df566
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/index.js
@@ -0,0 +1,12 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../../../modules/visTypes.js'
+import loadSingleValueSVG from './singleValue/index.js'
+
+export default function loadCustomSVG(visType) {
+ switch (visType) {
+ case VIS_TYPE_SINGLE_VALUE:
+ loadSingleValueSVG.call(this)
+ break
+ default:
+ break
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/addIconElement.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/addIconElement.js
new file mode 100644
index 000000000..dfa2c0c57
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/addIconElement.js
@@ -0,0 +1,32 @@
+const parser = new DOMParser()
+
+export function addIconElement(svgString, color) {
+ const svgIconDocument = parser.parseFromString(svgString, 'image/svg+xml')
+ const iconElHeight = svgIconDocument.documentElement.getAttribute('height')
+ const iconElWidth = svgIconDocument.documentElement.getAttribute('width')
+ const iconGroup = this.renderer
+ .g('icon')
+ .attr({ color, 'data-test': 'visualization-icon' })
+ .css({
+ visibility: 'hidden',
+ })
+
+ /* Force the group element to have the same dimensions as the original
+ * SVG image by adding this rect. This ensures the icon has the intended
+ * whitespace around it and makes scaling and translating easier. */
+ this.renderer.rect(0, 0, iconElWidth, iconElHeight).add(iconGroup)
+
+ Array.from(svgIconDocument.documentElement.children).forEach((pathNode) => {
+ /* It is also possible to use the SVGRenderer to draw the icon but that
+ * approach is more error prone, so during review it was decided to just
+ * append the SVG children to the iconGroup using native the native DOM
+ * API. For reference see this commit, for an implementation using the
+ * SVVGRenderer:
+ * https://github.com/dhis2/analytics/pull/1698/commits/f95bee838e07f4cdfc3cab6e92f28f49a386a0ad */
+ iconGroup.element.appendChild(pathNode)
+ })
+
+ iconGroup.add()
+
+ return iconGroup
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/checkIfFitsWithinContainer.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/checkIfFitsWithinContainer.js
new file mode 100644
index 000000000..182611977
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/checkIfFitsWithinContainer.js
@@ -0,0 +1,29 @@
+import { ACTUAL_NUMBER_HEIGHT_FACTOR } from './constants.js'
+
+export function checkIfFitsWithinContainer(
+ availableSpace,
+ valueElement,
+ subTextElement,
+ icon,
+ subText,
+ spacing
+) {
+ const valueRect = valueElement.getBBox(true)
+ const subTextRect = subText
+ ? subTextElement.getBBox(true)
+ : { width: 0, height: 0 }
+ const requiredValueWidth = icon
+ ? valueRect.width + spacing.iconGap + spacing.iconSize
+ : valueRect.width
+ const requiredHeight = subText
+ ? valueRect.height * ACTUAL_NUMBER_HEIGHT_FACTOR +
+ spacing.subTextTop +
+ subTextRect.height
+ : valueRect.height * ACTUAL_NUMBER_HEIGHT_FACTOR
+ const fitsHorizontally =
+ availableSpace.width > requiredValueWidth &&
+ availableSpace.width > subTextRect.width
+ const fitsVertically = availableSpace.height > requiredHeight
+
+ return fitsHorizontally && fitsVertically
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeLayoutRect.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeLayoutRect.js
new file mode 100644
index 000000000..a5d2705c9
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeLayoutRect.js
@@ -0,0 +1,43 @@
+import { computeSpacingTop } from './computeSpacingTop.js'
+import { ACTUAL_NUMBER_HEIGHT_FACTOR } from './constants.js'
+
+export function computeLayoutRect(
+ valueElement,
+ subTextElement,
+ iconElement,
+ spacing
+) {
+ const valueRect = valueElement.getBBox()
+ const containerCenterY = this.chartHeight / 2
+ const containerCenterX = this.chartWidth / 2
+ const minY = computeSpacingTop.call(this, spacing.valueTop)
+
+ let width = valueRect.width
+ let height = valueRect.height * ACTUAL_NUMBER_HEIGHT_FACTOR
+ let sideMarginTop = 0
+ let sideMarginBottom = 0
+
+ if (iconElement) {
+ width += spacing.iconGap + spacing.iconSize
+ }
+
+ if (subTextElement) {
+ const subTextRect = subTextElement.getBBox()
+ if (subTextRect.width > width) {
+ sideMarginTop = (subTextRect.width - width) / 2
+ width = subTextRect.width
+ } else {
+ sideMarginBottom = (width - subTextRect.width) / 2
+ }
+ height += spacing.subTextTop + subTextRect.height
+ }
+
+ return {
+ x: containerCenterX - width / 2,
+ y: Math.max(containerCenterY - height / 2, minY),
+ width,
+ height,
+ sideMarginTop,
+ sideMarginBottom,
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeSpacingTop.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeSpacingTop.js
new file mode 100644
index 000000000..1de00c836
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeSpacingTop.js
@@ -0,0 +1,15 @@
+export function computeSpacingTop(valueSpacingTop) {
+ if (this.subtitle.textStr) {
+ /* If a subtitle is present this will be below the title so base
+ * the value X position on this */
+ const subTitleRect = this.subtitle.element.getBBox()
+ return subTitleRect.y + subTitleRect.height + valueSpacingTop
+ } else if (this.title.textStr) {
+ // Otherwise base on title
+ const titleRect = this.title.element.getBBox()
+ return titleRect.y + titleRect.height + valueSpacingTop
+ } else {
+ // If neither are present only adjust for valueSpacingTop
+ return valueSpacingTop
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/constants.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/constants.js
new file mode 100644
index 000000000..b76e26a44
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/constants.js
@@ -0,0 +1,4 @@
+// multiply value text size with this factor
+// to get very close to the actual number height
+// as numbers don't go below the baseline like e.g. "j" and "g"
+export const ACTUAL_NUMBER_HEIGHT_FACTOR = 2 / 3
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/getAvailableSpace.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/getAvailableSpace.js
new file mode 100644
index 000000000..c9f567f4c
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/getAvailableSpace.js
@@ -0,0 +1,10 @@
+import { computeSpacingTop } from './computeSpacingTop.js'
+import { MIN_SIDE_WHITESPACE } from './styles.js'
+
+export function getAvailableSpace(valueSpacingTop) {
+ return {
+ height:
+ this.chartHeight - computeSpacingTop.call(this, valueSpacingTop),
+ width: this.chartWidth - MIN_SIDE_WHITESPACE * 2,
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js
new file mode 100644
index 000000000..84cc83e7d
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js
@@ -0,0 +1,55 @@
+import { addIconElement } from './addIconElement.js'
+import { checkIfFitsWithinContainer } from './checkIfFitsWithinContainer.js'
+import { getAvailableSpace } from './getAvailableSpace.js'
+import { positionElements } from './positionElements.js'
+import { DynamicStyles } from './styles.js'
+
+export default function loadSingleValueSVG() {
+ const { formattedValue, icon, subText, fontColor } =
+ this.userOptions.customSVGOptions
+ const dynamicStyles = new DynamicStyles(this.userOptions?.isPdfExport)
+ const valueElement = this.renderer
+ .text(formattedValue)
+ .attr('data-test', 'visualization-primary-value')
+ .css({ color: fontColor, visibility: 'hidden' })
+ .add()
+ const subTextElement = subText
+ ? this.renderer
+ .text(subText)
+ .attr('data-test', 'visualization-subtext')
+ .css({ color: fontColor, visibility: 'hidden' })
+ .add()
+ : null
+ const iconElement = icon ? addIconElement.call(this, icon, fontColor) : null
+
+ let fitsWithinContainer = false
+ let styles = {}
+
+ while (!fitsWithinContainer && dynamicStyles.hasNext()) {
+ styles = dynamicStyles.next()
+
+ valueElement.css(styles.value)
+ subTextElement?.css(styles.subText)
+
+ fitsWithinContainer = checkIfFitsWithinContainer(
+ getAvailableSpace.call(this, styles.spacing.valueTop),
+ valueElement,
+ subTextElement,
+ icon,
+ subText,
+ styles.spacing
+ )
+ }
+
+ positionElements.call(
+ this,
+ valueElement,
+ subTextElement,
+ iconElement,
+ styles.spacing
+ )
+
+ valueElement.css({ visibility: 'visible' })
+ iconElement?.css({ visibility: 'visible' })
+ subTextElement?.css({ visibility: 'visible' })
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/positionElements.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/positionElements.js
new file mode 100644
index 000000000..052c86b5b
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/positionElements.js
@@ -0,0 +1,62 @@
+import { computeLayoutRect } from './computeLayoutRect.js'
+import { ACTUAL_NUMBER_HEIGHT_FACTOR } from './constants.js'
+
+export function positionElements(
+ valueElement,
+ subTextElement,
+ iconElement,
+ spacing
+) {
+ const valueElementBox = valueElement.getBBox()
+ /* Layout here refers to a virtual rect that wraps
+ * all indiviual parts of the single value visualization
+ * (value, subtext and icon) */
+ const layoutRect = computeLayoutRect.call(
+ this,
+ valueElement,
+ subTextElement,
+ iconElement,
+ spacing
+ )
+
+ valueElement.align(
+ {
+ align: 'right',
+ verticalAlign: 'top',
+ alignByTranslate: false,
+ x: (valueElementBox.width + layoutRect.sideMarginTop) * -1,
+ y: valueElementBox.height * ACTUAL_NUMBER_HEIGHT_FACTOR,
+ },
+ false,
+ layoutRect
+ )
+
+ if (iconElement) {
+ const { height } = iconElement.getBBox()
+ const scale = spacing.iconSize / height
+ const translateX = layoutRect.x + layoutRect.sideMarginTop
+ const iconHeight = height * scale
+ const valueElementHeight =
+ valueElementBox.height * ACTUAL_NUMBER_HEIGHT_FACTOR
+ const translateY = layoutRect.y + (valueElementHeight - iconHeight) / 2
+
+ /* The icon is a
with elements that contain coordinates.
+ * These path-coordinates only scale correctly when using CSS translate */
+ iconElement.css({
+ transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
+ })
+ }
+
+ if (subTextElement) {
+ subTextElement.align(
+ {
+ align: 'left',
+ verticalAlign: 'bottom',
+ alignByTranslate: false,
+ x: layoutRect.sideMarginBottom,
+ },
+ false,
+ layoutRect
+ )
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js
new file mode 100644
index 000000000..f1b944ee2
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js
@@ -0,0 +1,62 @@
+const valueStyles = [
+ { 'font-size': '164px', 'letter-spacing': '-5px' },
+ { 'font-size': '128px', 'letter-spacing': '-4px' },
+ { 'font-size': '96px', 'letter-spacing': '-3px' },
+ { 'font-size': '64px', 'letter-spacing': '-2.5px' },
+ { 'font-size': '40px', 'letter-spacing': '-1.5px' },
+ { 'font-size': '20px', 'letter-spacing': '-1px' },
+]
+
+const subTextStyles = [
+ { 'font-size': '36px', 'letter-spacing': '-1.4px' },
+ { 'font-size': '32px', 'letter-spacing': '-1.2px' },
+ { 'font-size': '26px', 'letter-spacing': '-0.8px' },
+ { 'font-size': '20px', 'letter-spacing': '-0.6px' },
+ { 'font-size': '14px', 'letter-spacing': '0.2px' },
+ { 'font-size': '9px', 'letter-spacing': '0px' },
+]
+
+const spacings = [
+ { valueTop: 8, subTextTop: 12, iconGap: 8, iconSize: 164 },
+ { valueTop: 8, subTextTop: 12, iconGap: 6, iconSize: 128 },
+ { valueTop: 8, subTextTop: 8, iconGap: 4, iconSize: 96 },
+ { valueTop: 8, subTextTop: 8, iconGap: 4, iconSize: 64 },
+ { valueTop: 8, subTextTop: 8, iconGap: 4, iconSize: 40 },
+ { valueTop: 8, subTextTop: 4, iconGap: 2, iconSize: 20 },
+]
+
+export const MIN_SIDE_WHITESPACE = 4
+
+export class DynamicStyles {
+ constructor(isPdfExport) {
+ this.currentIndex = 0
+ this.isPdfExport = isPdfExport
+ }
+ getStyle() {
+ return {
+ value: {
+ ...valueStyles[this.currentIndex],
+ 'font-weight': this.isPdfExport ? 'normal' : '300',
+ },
+ subText: subTextStyles[this.currentIndex],
+ spacing: spacings[this.currentIndex],
+ }
+ }
+ next() {
+ if (this.currentIndex === valueStyles.length - 1) {
+ throw new Error('No next available, already on the smallest style')
+ } else {
+ ++this.currentIndex
+ }
+
+ return this.getStyle()
+ }
+ first() {
+ this.currentIndex = 0
+
+ return this.getStyle()
+ }
+ hasNext() {
+ return this.currentIndex < valueStyles.length - 1
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/exporting.js b/src/visualizations/config/adapters/dhis_highcharts/exporting.js
new file mode 100644
index 000000000..032a9c689
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/exporting.js
@@ -0,0 +1,25 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
+import loadSingleValueSVG from './events/loadCustomSVG/singleValue/index.js'
+
+export default function getExporting(visType) {
+ const exporting = {
+ // disable exporting context menu
+ enabled: false,
+ }
+ switch (visType) {
+ case VIS_TYPE_SINGLE_VALUE:
+ return {
+ ...exporting,
+ chartOptions: {
+ chart: {
+ events: {
+ load: loadSingleValueSVG,
+ },
+ },
+ },
+ }
+
+ default:
+ return exporting
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/index.js b/src/visualizations/config/adapters/dhis_highcharts/index.js
index 29ecf41c0..0f3ddb271 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/index.js
@@ -14,10 +14,13 @@ import {
} from '../../../../modules/visTypes.js'
import { defaultMultiAxisTheme1 } from '../../../util/colors/themes.js'
import addTrendLines, { isRegressionIneligible } from './addTrendLines.js'
-import getChart from './chart.js'
+import getChart from './chart/index.js'
+import getCustomSVGOptions from './customSVGOptions/index.js'
+import getExporting from './exporting.js'
import getScatterData from './getScatterData.js'
import getSortedConfig from './getSortedConfig.js'
import getTrimmedConfig from './getTrimmedConfig.js'
+import getLang from './lang.js'
import getLegend from './legend.js'
import { applyLegendSet, getLegendSetTooltip } from './legendSet.js'
import getNoData from './noData.js'
@@ -77,21 +80,17 @@ export default function ({ store, layout, el, extraConfig, extraOptions }) {
let config = {
// type etc
- chart: getChart(_layout, el, _extraOptions.dashboard),
+ chart: getChart(_layout, el, _extraOptions, series),
// title
- title: getTitle(
- _layout,
- store.data[0].metaData,
- _extraOptions.dashboard
- ),
+ title: getTitle(_layout, store.data[0].metaData, _extraOptions, series),
// subtitle
subtitle: getSubtitle(
series,
_layout,
store.data[0].metaData,
- _extraOptions.dashboard
+ _extraOptions
),
// x-axis
@@ -123,11 +122,8 @@ export default function ({ store, layout, el, extraConfig, extraOptions }) {
pane: getPane(_layout.type),
// no data + zoom
- lang: {
- noData: _extraOptions.noData.text,
- resetZoom: _extraOptions.resetZoom.text,
- },
- noData: getNoData(),
+ lang: getLang(_layout.type, _extraOptions),
+ noData: getNoData(_layout.type),
// credits
credits: {
@@ -135,10 +131,20 @@ export default function ({ store, layout, el, extraConfig, extraOptions }) {
},
// exporting
- exporting: {
- // disable exporting context menu
- enabled: false,
- },
+ exporting: getExporting(_layout.type),
+
+ /* The config object passed to the Highcharts Chart constructor
+ * can contain arbitrary properties, which are made accessible
+ * under the Chart instance's `userOptions` member. This means
+ * that in event callback functions the custom SVG options are
+ * accessible as `this.userOptions.customSVGOptions` */
+ customSVGOptions: getCustomSVGOptions({
+ extraConfig,
+ layout: _layout,
+ extraOptions: _extraOptions,
+ metaData: store.data[0].metaData,
+ series,
+ }),
}
// get plot options for scatter
@@ -234,5 +240,7 @@ export default function ({ store, layout, el, extraConfig, extraOptions }) {
// force apply extra config
Object.assign(config, extraConfig)
+ console.log(objectClean(config))
+
return objectClean(config)
}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/lang.js b/src/visualizations/config/adapters/dhis_highcharts/lang.js
new file mode 100644
index 000000000..80299fe41
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/lang.js
@@ -0,0 +1,15 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
+
+export default function getLang(visType, extraOptions) {
+ return {
+ /* The SingleValue visualization consists of some custom SVG elements
+ * rendered on an empty chart. Since the chart is empty, there is never
+ * any data and Highcharts will show the noData text. To avoid this we
+ * clear the text here. */
+ noData:
+ visType === VIS_TYPE_SINGLE_VALUE
+ ? undefined
+ : extraOptions.noData.text,
+ resetZoom: extraOptions.resetZoom.text,
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/plotOptions.js b/src/visualizations/config/adapters/dhis_highcharts/plotOptions.js
index 928019506..e9e775096 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/plotOptions.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/plotOptions.js
@@ -79,6 +79,6 @@ export default ({
}
: {}
default:
- return {}
+ return null
}
}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/series/__tests__/pie.spec.js b/src/visualizations/config/adapters/dhis_highcharts/series/__tests__/pie.spec.js
new file mode 100644
index 000000000..eb9abac19
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/series/__tests__/pie.spec.js
@@ -0,0 +1,66 @@
+import { formatDataLabel } from '../pie.js'
+
+describe('formatDataLabel', () => {
+ it('should format data label correctly with integers', () => {
+ const result = formatDataLabel('Test', 1000, 50)
+ expect(result).toEqual(
+ 'Test
1 000 (50%)'
+ )
+ })
+
+ it('should format data label correctly with decimals', () => {
+ const result = formatDataLabel('Test', 1000.123456789, 50.1234)
+ expect(result).toEqual(
+ 'Test
1 000.123456789 (50.1%)'
+ )
+ })
+
+ it('should handle large numbers correctly', () => {
+ const result = formatDataLabel('Test', 1000000, 75.5678)
+ expect(result).toEqual(
+ 'Test
1 000 000 (75.6%)'
+ )
+ })
+
+ it('should handle small percentages correctly', () => {
+ const result = formatDataLabel('Test', 1000.000001, 0.09)
+ expect(result).toEqual(
+ 'Test
1 000.000001 (0.1%)'
+ )
+ })
+
+ it('should handle zero correctly', () => {
+ const result = formatDataLabel('Test', 0, 0)
+ expect(result).toEqual(
+ 'Test
0 (0%)'
+ )
+ })
+
+ it('should handle negative numbers correctly', () => {
+ const result = formatDataLabel('Test', -1000, -50)
+ expect(result).toEqual(
+ 'Test
-1 000 (-50%)'
+ )
+ })
+
+ it('should handle empty string as name correctly', () => {
+ const result = formatDataLabel('', 1000, 50)
+ expect(result).toEqual(
+ '
1 000 (50%)'
+ )
+ })
+
+ it('should handle undefined as name correctly', () => {
+ const result = formatDataLabel(undefined, 1000, 50)
+ expect(result).toEqual(
+ '
1 000 (50%)'
+ )
+ })
+
+ it('should handle special characters in name correctly', () => {
+ const result = formatDataLabel('Test&Test', 1000, 50)
+ expect(result).toEqual(
+ 'Test&Test
1 000 (50%)'
+ )
+ })
+})
diff --git a/src/visualizations/config/adapters/dhis_highcharts/series/index.js b/src/visualizations/config/adapters/dhis_highcharts/series/index.js
index e4d4eae67..e4ec840f0 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/series/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/series/index.js
@@ -9,6 +9,7 @@ import {
isYearOverYear,
VIS_TYPE_LINE,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import { getAxisStringFromId } from '../../../../util/axisId.js'
import {
@@ -225,6 +226,9 @@ export default function ({
displayStrategy,
}) {
switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ series = []
+ break
case VIS_TYPE_PIE:
series = getPie(
series,
@@ -249,7 +253,7 @@ export default function ({
})
}
- series.forEach((seriesObj) => {
+ series?.forEach((seriesObj) => {
// animation
seriesObj.animation = {
duration: getAnimation(
diff --git a/src/visualizations/config/adapters/dhis_highcharts/series/pie.js b/src/visualizations/config/adapters/dhis_highcharts/series/pie.js
index 67a4e772d..b5afb2c49 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/series/pie.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/series/pie.js
@@ -1,3 +1,18 @@
+import { separateDigitGroups } from '../../../../../modules/renderValue.js'
+
+export const formatDataLabel = (name = '', y, percentage) => {
+ const value = separateDigitGroups(y.toString()).join(' ')
+ return (
+ '' +
+ name +
+ '
' +
+ value +
+ ' (' +
+ parseFloat(percentage.toFixed(1)) +
+ '%)'
+ )
+}
+
export default function (series, colors) {
return [
{
@@ -9,14 +24,10 @@ export default function (series, colors) {
dataLabels: {
enabled: true,
formatter: function () {
- return (
- '' +
- this.point.name +
- '
' +
- this.y +
- ' (' +
- this.percentage.toFixed(1) +
- ' %)'
+ return formatDataLabel(
+ this.point.name,
+ this.y,
+ this.percentage
)
},
},
diff --git a/src/visualizations/config/adapters/dhis_highcharts/subtitle/__tests__/singleValue.spec.js b/src/visualizations/config/adapters/dhis_highcharts/subtitle/__tests__/singleValue.spec.js
new file mode 100644
index 000000000..c7baa2ad6
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/subtitle/__tests__/singleValue.spec.js
@@ -0,0 +1,64 @@
+import getSingleValueSubtitle from '../singleValue.js'
+
+jest.mock(
+ '../../../../../util/getFilterText',
+ () => () => 'The default filter text'
+)
+
+describe('getSingleValueSubtitle', () => {
+ it('returns empty subtitle when flag hideSubtitle exists', () => {
+ expect(getSingleValueSubtitle({ hideSubtitle: true })).toEqual('')
+ })
+
+ it('returns the subtitle provided in the layout', () => {
+ const subtitle = 'The subtitle was already set'
+ expect(getSingleValueSubtitle({ subtitle })).toEqual(subtitle)
+ })
+
+ it('returns an empty string when layout does not have filters', () => {
+ expect(getSingleValueSubtitle({})).toEqual('')
+ })
+
+ it('returns the filter text', () => {
+ expect(getSingleValueSubtitle({ filters: [] })).toEqual(
+ 'The default filter text'
+ )
+ })
+
+ describe('not dashboard', () => {
+ describe('layout does not include title', () => {
+ it('returns empty subtitle', () => {
+ expect(
+ getSingleValueSubtitle({ filters: undefined }, {}, false)
+ ).toEqual('')
+ })
+ })
+
+ /* All these tests have been moved and adjusted from here:
+ * src/visualizations/config/adapters/dhis_dhis/title/__tests__`
+ * The test below asserted the default subtitle behaviour, for
+ * visualization types other than SingleValue. It expected that
+ * the title was being used as subtitle. It fails now, and I
+ * believe that this behaviour does not make sense. So instead
+ * of fixing it, I disabled it. */
+ // describe('layout includes title', () => {
+ // it('returns filter title as subtitle', () => {
+ // expect(
+ // getSingleValueSubtitle(
+ // { filters: undefined, title: 'Chart title' },
+ // {},
+ // false
+ // )
+ // ).toEqual('The default filter text')
+ // })
+ // })
+ })
+
+ describe('dashboard', () => {
+ it('returns filter title as subtitle', () => {
+ expect(getSingleValueSubtitle({ filters: {} }, {}, true)).toEqual(
+ 'The default filter text'
+ )
+ })
+ })
+})
diff --git a/src/visualizations/config/adapters/dhis_highcharts/subtitle/index.js b/src/visualizations/config/adapters/dhis_highcharts/subtitle/index.js
index 585842325..6509c3e5a 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/subtitle/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/subtitle/index.js
@@ -7,16 +7,21 @@ import {
FONT_STYLE_OPTION_TEXT_ALIGN,
FONT_STYLE_VISUALIZATION_SUBTITLE,
mergeFontStyleWithDefault,
+ defaultFontStyle,
} from '../../../../../modules/fontStyle.js'
import {
VIS_TYPE_YEAR_OVER_YEAR_LINE,
VIS_TYPE_YEAR_OVER_YEAR_COLUMN,
isVerticalType,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import getFilterText from '../../../../util/getFilterText.js'
import { getTextAlignOption } from '../getTextAlignOption.js'
import getYearOverYearTitle from '../title/yearOverYear.js'
+import getSingleValueSubtitle, {
+ getSingleValueSubtitleColor,
+} from './singleValue.js'
const DASHBOARD_SUBTITLE = {
style: {
@@ -31,31 +36,62 @@ const DASHBOARD_SUBTITLE = {
}
function getDefault(layout, dashboard, filterTitle) {
- return {
- text: dashboard || isString(layout.title) ? filterTitle : undefined,
- }
+ return dashboard || isString(layout.title) ? filterTitle : undefined
}
-export default function (series, layout, metaData, dashboard) {
+export default function (series, layout, metaData, extraOptions) {
+ if (layout.hideSubtitle) {
+ return null
+ }
+
+ const { dashboard, legendSets } = extraOptions
+ const legendOptions = layout.legend
const fontStyle = mergeFontStyleWithDefault(
layout.fontStyle && layout.fontStyle[FONT_STYLE_VISUALIZATION_SUBTITLE],
FONT_STYLE_VISUALIZATION_SUBTITLE
)
- let subtitle = {
- text: undefined,
- }
-
- if (layout.hideSubtitle) {
- return null
- }
+ const subtitle = Object.assign(
+ {
+ text: undefined,
+ },
+ dashboard
+ ? DASHBOARD_SUBTITLE
+ : {
+ align: getTextAlignOption(
+ fontStyle[FONT_STYLE_OPTION_TEXT_ALIGN],
+ FONT_STYLE_VISUALIZATION_SUBTITLE,
+ isVerticalType(layout.type)
+ ),
+ style: {
+ // DHIS2-578: dynamically truncate subtitle when it's taking more than 1 line
+ color: undefined,
+ fontSize: `${fontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
+ fontWeight: fontStyle[FONT_STYLE_OPTION_BOLD]
+ ? FONT_STYLE_OPTION_BOLD
+ : 'normal',
+ fontStyle: fontStyle[FONT_STYLE_OPTION_ITALIC]
+ ? FONT_STYLE_OPTION_ITALIC
+ : 'normal',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ },
+ }
+ )
// DHIS2-578: allow for optional custom subtitle
- if (isString(layout.subtitle)) {
- subtitle.text = layout.subtitle
+ const customSubtitle =
+ (layout.subtitle && layout.displaySubtitle) || layout.subtitle
+
+ if (isString(customSubtitle) && customSubtitle.length) {
+ subtitle.text = customSubtitle
} else {
const filterTitle = getFilterText(layout.filters, metaData)
switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ subtitle.text = getSingleValueSubtitle(layout, metaData)
+ break
case VIS_TYPE_YEAR_OVER_YEAR_LINE:
case VIS_TYPE_YEAR_OVER_YEAR_COLUMN:
subtitle.text = getYearOverYearTitle(
@@ -68,37 +104,46 @@ export default function (series, layout, metaData, dashboard) {
subtitle.text = filterTitle
break
default:
- subtitle = getDefault(layout, dashboard, filterTitle)
+ subtitle.text = getDefault(layout, dashboard, filterTitle)
}
}
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ {
+ const defaultColor =
+ defaultFontStyle?.[FONT_STYLE_VISUALIZATION_SUBTITLE]?.[
+ FONT_STYLE_OPTION_TEXT_COLOR
+ ]
+ const customColor =
+ layout?.fontStyle?.[FONT_STYLE_VISUALIZATION_SUBTITLE]?.[
+ FONT_STYLE_OPTION_TEXT_COLOR
+ ]
+ subtitle.style.color = getSingleValueSubtitleColor(
+ customColor,
+ defaultColor,
+ series[0],
+ legendOptions,
+ legendSets
+ )
+ if (dashboard) {
+ // Single value subtitle text should be multiline
+ /* TODO: The default color of the subtitle now is #4a5768 but the
+ * original implementation used #666, which is a lighter grey.
+ * If we want to keep this color, changes are needed here. */
+ Object.assign(subtitle.style, {
+ wordWrap: 'normal',
+ whiteSpace: 'normal',
+ overflow: 'visible',
+ textOverflow: 'initial',
+ })
+ }
+ }
+ break
+ default:
+ subtitle.style.color = fontStyle[FONT_STYLE_OPTION_TEXT_COLOR]
+ break
+ }
+
return subtitle
- ? Object.assign(
- {},
- dashboard
- ? DASHBOARD_SUBTITLE
- : {
- align: getTextAlignOption(
- fontStyle[FONT_STYLE_OPTION_TEXT_ALIGN],
- FONT_STYLE_VISUALIZATION_SUBTITLE,
- isVerticalType(layout.type)
- ),
- style: {
- // DHIS2-578: dynamically truncate subtitle when it's taking more than 1 line
- color: fontStyle[FONT_STYLE_OPTION_TEXT_COLOR],
- fontSize: `${fontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
- fontWeight: fontStyle[FONT_STYLE_OPTION_BOLD]
- ? FONT_STYLE_OPTION_BOLD
- : 'normal',
- fontStyle: fontStyle[FONT_STYLE_OPTION_ITALIC]
- ? FONT_STYLE_OPTION_ITALIC
- : 'normal',
- whiteSpace: 'nowrap',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- },
- },
- subtitle
- )
- : subtitle
}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/subtitle/singleValue.js b/src/visualizations/config/adapters/dhis_highcharts/subtitle/singleValue.js
new file mode 100644
index 000000000..922f142cf
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/subtitle/singleValue.js
@@ -0,0 +1,18 @@
+import getFilterText from '../../../../util/getFilterText.js'
+export { getSingleValueTitleColor as getSingleValueSubtitleColor } from '../customSVGOptions/singleValue/getSingleValueTitleColor.js'
+
+export default function getSingleValueSubtitle(layout, metaData) {
+ if (layout.hideSubtitle || 1 === 0) {
+ return ''
+ }
+
+ if (typeof layout.subtitle === 'string' && layout.subtitle.length) {
+ return layout.subtitle
+ }
+
+ if (layout.filters) {
+ return getFilterText(layout.filters, metaData)
+ }
+
+ return ''
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/title/__tests__/singleValue.spec.js b/src/visualizations/config/adapters/dhis_highcharts/title/__tests__/singleValue.spec.js
new file mode 100644
index 000000000..bc8022f81
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/title/__tests__/singleValue.spec.js
@@ -0,0 +1,57 @@
+import { getSingleValueTitleText } from '../singleValue.js'
+
+jest.mock('../../../../../util/getFilterText', () => () => 'The filter text')
+
+describe('getSingleValueTitle', () => {
+ it('returns empty title when flag hideTitle exists', () => {
+ expect(getSingleValueTitleText({ hideTitle: true })).toEqual('')
+ })
+
+ it('returns the title provided in the layout', () => {
+ const title = 'The title was already set'
+ expect(getSingleValueTitleText({ title })).toEqual(title)
+ })
+
+ it('returns null when layout does not have columns', () => {
+ expect(getSingleValueTitleText({})).toEqual('')
+ })
+
+ it('returns the filter text based on column items', () => {
+ expect(
+ getSingleValueTitleText({
+ columns: [
+ {
+ items: [{}],
+ },
+ ],
+ })
+ ).toEqual('The filter text')
+ })
+
+ describe('not dashboard', () => {
+ it('returns filter text as title', () => {
+ expect(
+ getSingleValueTitleText(
+ {
+ columns: [
+ {
+ items: [{}],
+ },
+ ],
+ filters: [],
+ },
+ {},
+ false
+ )
+ ).toEqual('The filter text')
+ })
+ })
+
+ describe('dashboard', () => {
+ it('returns empty string', () => {
+ expect(getSingleValueTitleText({ filters: {} }, {}, true)).toEqual(
+ ''
+ )
+ })
+ })
+})
diff --git a/src/visualizations/config/adapters/dhis_highcharts/title/index.js b/src/visualizations/config/adapters/dhis_highcharts/title/index.js
index 3a42cb5f7..7a86ec47f 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/title/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/title/index.js
@@ -7,6 +7,7 @@ import {
FONT_STYLE_OPTION_TEXT_ALIGN,
FONT_STYLE_VISUALIZATION_TITLE,
mergeFontStyleWithDefault,
+ defaultFontStyle,
} from '../../../../../modules/fontStyle.js'
import {
VIS_TYPE_YEAR_OVER_YEAR_LINE,
@@ -14,10 +15,15 @@ import {
VIS_TYPE_GAUGE,
isVerticalType,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import getFilterText from '../../../../util/getFilterText.js'
import { getTextAlignOption } from '../getTextAlignOption.js'
import getScatterTitle from './scatter.js'
+import {
+ getSingleValueTitleColor,
+ getSingleValueTitleText,
+} from './singleValue.js'
import getYearOverYearTitle from './yearOverYear.js'
const DASHBOARD_TITLE_STYLE = {
@@ -41,40 +47,22 @@ function getDefault(layout, metaData, dashboard) {
return null
}
-export default function (layout, metaData, dashboard) {
+export default function (layout, metaData, extraOptions, series) {
+ if (layout.hideTitle) {
+ return {
+ text: undefined,
+ }
+ }
+ const { dashboard, legendSets } = extraOptions
+ const legendOptions = layout.legend
const fontStyle = mergeFontStyleWithDefault(
layout.fontStyle && layout.fontStyle[FONT_STYLE_VISUALIZATION_TITLE],
FONT_STYLE_VISUALIZATION_TITLE
)
-
- const title = {
- text: undefined,
- }
-
- if (layout.hideTitle) {
- return title
- }
-
- if (isString(layout.title) && layout.title.length) {
- title.text = layout.title
- } else {
- switch (layout.type) {
- case VIS_TYPE_GAUGE:
- case VIS_TYPE_YEAR_OVER_YEAR_LINE:
- case VIS_TYPE_YEAR_OVER_YEAR_COLUMN:
- title.text = getYearOverYearTitle(layout, metaData, dashboard)
- break
- case VIS_TYPE_SCATTER:
- title.text = getScatterTitle(layout, metaData, dashboard)
- break
- default:
- title.text = getDefault(layout, metaData, dashboard)
- break
- }
- }
-
- return Object.assign(
- {},
+ const title = Object.assign(
+ {
+ text: undefined,
+ },
dashboard
? DASHBOARD_TITLE_STYLE
: {
@@ -85,7 +73,7 @@ export default function (layout, metaData, dashboard) {
isVerticalType(layout.type)
),
style: {
- color: fontStyle[FONT_STYLE_OPTION_TEXT_COLOR],
+ color: undefined,
fontSize: `${fontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
fontWeight: fontStyle[FONT_STYLE_OPTION_BOLD]
? FONT_STYLE_OPTION_BOLD
@@ -97,7 +85,65 @@ export default function (layout, metaData, dashboard) {
overflow: 'hidden',
textOverflow: 'ellipsis',
},
- },
- title
+ }
)
+
+ const customTitleText =
+ (layout.title && layout.displayTitle) || layout.title
+
+ if (isString(customTitleText) && customTitleText.length) {
+ title.text = customTitleText
+ } else {
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ title.text = getSingleValueTitleText(
+ layout,
+ metaData,
+ dashboard
+ )
+ break
+ case VIS_TYPE_GAUGE:
+ case VIS_TYPE_YEAR_OVER_YEAR_LINE:
+ case VIS_TYPE_YEAR_OVER_YEAR_COLUMN:
+ title.text = getYearOverYearTitle(layout, metaData, dashboard)
+ break
+ case VIS_TYPE_SCATTER:
+ title.text = getScatterTitle(layout, metaData, dashboard)
+ break
+ default:
+ title.text = getDefault(layout, metaData, dashboard)
+ break
+ }
+ }
+
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ {
+ const defaultColor =
+ defaultFontStyle?.[FONT_STYLE_VISUALIZATION_TITLE]?.[
+ FONT_STYLE_OPTION_TEXT_COLOR
+ ]
+ const customColor =
+ layout?.fontStyle?.[FONT_STYLE_VISUALIZATION_TITLE]?.[
+ FONT_STYLE_OPTION_TEXT_COLOR
+ ]
+ title.style.color = getSingleValueTitleColor(
+ customColor,
+ defaultColor,
+ series[0],
+ legendOptions,
+ legendSets
+ )
+ if (dashboard) {
+ // TODO: is this always what we want?
+ title.style.fontWeight = 'normal'
+ }
+ }
+ break
+ default:
+ title.style.color = fontStyle[FONT_STYLE_OPTION_TEXT_COLOR]
+ break
+ }
+
+ return title
}
diff --git a/src/visualizations/config/adapters/dhis_dhis/title/singleValue.js b/src/visualizations/config/adapters/dhis_highcharts/title/singleValue.js
similarity index 50%
rename from src/visualizations/config/adapters/dhis_dhis/title/singleValue.js
rename to src/visualizations/config/adapters/dhis_highcharts/title/singleValue.js
index 802c866c0..fdf5d891a 100644
--- a/src/visualizations/config/adapters/dhis_dhis/title/singleValue.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/title/singleValue.js
@@ -1,6 +1,15 @@
import getFilterText from '../../../../util/getFilterText.js'
+export { getSingleValueTitleColor } from '../customSVGOptions/singleValue/getSingleValueTitleColor.js'
+
+export function getSingleValueTitleText(layout, metaData) {
+ if (layout.hideTitle) {
+ return ''
+ }
+
+ if (typeof layout.title === 'string' && layout.title.length) {
+ return layout.title
+ }
-export default function (layout, metaData) {
if (layout.columns) {
const firstItem = layout.columns[0].items[0]
@@ -10,6 +19,5 @@ export default function (layout, metaData) {
return getFilterText([column], metaData)
}
-
return ''
}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/type.js b/src/visualizations/config/adapters/dhis_highcharts/type.js
index bc56c6d98..08cb62a49 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/type.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/type.js
@@ -12,6 +12,7 @@ import {
VIS_TYPE_STACKED_COLUMN,
VIS_TYPE_YEAR_OVER_YEAR_COLUMN,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../modules/visTypes.js'
export default function (type) {
@@ -33,6 +34,8 @@ export default function (type) {
return { type: 'solidgauge' }
case VIS_TYPE_SCATTER:
return { type: 'scatter', zoomType: 'xy' }
+ case VIS_TYPE_SINGLE_VALUE:
+ return {}
case VIS_TYPE_COLUMN:
case VIS_TYPE_STACKED_COLUMN:
case VIS_TYPE_YEAR_OVER_YEAR_COLUMN:
diff --git a/src/visualizations/config/adapters/dhis_highcharts/xAxis/index.js b/src/visualizations/config/adapters/dhis_highcharts/xAxis/index.js
index c3af4b20b..1439fc201 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/xAxis/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/xAxis/index.js
@@ -16,6 +16,7 @@ import {
VIS_TYPE_RADAR,
VIS_TYPE_SCATTER,
isTwoCategoryChartType,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import { getAxis } from '../../../../util/axes.js'
import getAxisTitle from '../getAxisTitle.js'
@@ -82,6 +83,7 @@ export default function (store, layout, extraOptions, series) {
switch (layout.type) {
case VIS_TYPE_PIE:
case VIS_TYPE_GAUGE:
+ case VIS_TYPE_SINGLE_VALUE:
xAxis = noAxis()
break
case VIS_TYPE_YEAR_OVER_YEAR_LINE:
diff --git a/src/visualizations/config/adapters/dhis_highcharts/yAxis/index.js b/src/visualizations/config/adapters/dhis_highcharts/yAxis/index.js
index 1e9aab2a9..d253acdff 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/yAxis/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/yAxis/index.js
@@ -11,6 +11,7 @@ import {
isStacked,
VIS_TYPE_GAUGE,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import { getAxis } from '../../../../util/axes.js'
import { getAxisStringFromId } from '../../../../util/axisId.js'
@@ -148,14 +149,12 @@ function getDefault(layout, series, extraOptions) {
}
export default function (layout, series, extraOptions) {
- let yAxis
switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ return null
case VIS_TYPE_GAUGE:
- yAxis = getGauge(layout, series, extraOptions.legendSets[0])
- break
+ return getGauge(layout, series, extraOptions.legendSets[0])
default:
- yAxis = getDefault(layout, series, extraOptions)
+ return getDefault(layout, series, extraOptions)
}
-
- return yAxis
}
diff --git a/src/visualizations/config/adapters/index.js b/src/visualizations/config/adapters/index.js
index 7b49438ee..4db1838e0 100644
--- a/src/visualizations/config/adapters/index.js
+++ b/src/visualizations/config/adapters/index.js
@@ -1,7 +1,5 @@
-import dhis_dhis from './dhis_dhis/index.js'
import dhis_highcharts from './dhis_highcharts/index.js'
export default {
dhis_highcharts,
- dhis_dhis,
}
diff --git a/src/visualizations/config/generators/dhis/index.js b/src/visualizations/config/generators/dhis/index.js
deleted file mode 100644
index b5a6c3958..000000000
--- a/src/visualizations/config/generators/dhis/index.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
-import getSingleValueGenerator from './singleValue.js'
-
-export default function (config, parentEl, extraOptions) {
- if (config) {
- const node =
- typeof parentEl === 'object'
- ? parentEl
- : typeof parentEl === 'string'
- ? document.querySelector(parentEl)
- : null
-
- if (node) {
- if (node.lastChild) {
- node.removeChild(node.lastChild)
- }
-
- let content
-
- switch (config.type) {
- case VIS_TYPE_SINGLE_VALUE:
- default:
- content = getSingleValueGenerator(
- config,
- node,
- extraOptions
- )
- break
- }
-
- node.appendChild(content)
-
- return node.innerHTML
- }
- }
-}
diff --git a/src/visualizations/config/generators/dhis/singleValue.js b/src/visualizations/config/generators/dhis/singleValue.js
deleted file mode 100644
index 25ec5bab9..000000000
--- a/src/visualizations/config/generators/dhis/singleValue.js
+++ /dev/null
@@ -1,531 +0,0 @@
-import { colors } from '@dhis2/ui'
-import {
- FONT_STYLE_VISUALIZATION_TITLE,
- FONT_STYLE_VISUALIZATION_SUBTITLE,
- FONT_STYLE_OPTION_FONT_SIZE,
- FONT_STYLE_OPTION_TEXT_COLOR,
- FONT_STYLE_OPTION_TEXT_ALIGN,
- FONT_STYLE_OPTION_ITALIC,
- FONT_STYLE_OPTION_BOLD,
- TEXT_ALIGN_LEFT,
- TEXT_ALIGN_RIGHT,
- TEXT_ALIGN_CENTER,
- mergeFontStyleWithDefault,
- defaultFontStyle,
-} from '../../../../modules/fontStyle.js'
-import {
- getColorByValueFromLegendSet,
- LEGEND_DISPLAY_STYLE_FILL,
-} from '../../../../modules/legends.js'
-
-const svgNS = 'http://www.w3.org/2000/svg'
-
-// multiply text width with this factor
-// to get very close to actual text width
-// nb: dependent on viewbox etc
-const ACTUAL_TEXT_WIDTH_FACTOR = 0.9
-
-// multiply value text size with this factor
-// to get very close to the actual number height
-// as numbers don't go below the baseline like e.g. "j" and "g"
-const ACTUAL_NUMBER_HEIGHT_FACTOR = 0.67
-
-// do not allow text width to exceed this threshold
-// a threshold >1 does not really make sense but text width vs viewbox is complicated
-const TEXT_WIDTH_CONTAINER_WIDTH_FACTOR = 1.3
-
-// do not allow text size to exceed this
-const TEXT_SIZE_CONTAINER_HEIGHT_FACTOR = 0.6
-const TEXT_SIZE_MAX_THRESHOLD = 400
-
-// multiply text size with this factor
-// to get an appropriate letter spacing
-const LETTER_SPACING_TEXT_SIZE_FACTOR = (1 / 35) * -1
-const LETTER_SPACING_MIN_THRESHOLD = -6
-const LETTER_SPACING_MAX_THRESHOLD = -1
-
-// fixed top margin above title/subtitle
-const TOP_MARGIN_FIXED = 16
-
-// multiply text size with this factor
-// to get an appropriate sub text size
-const SUB_TEXT_SIZE_FACTOR = 0.5
-const SUB_TEXT_SIZE_MIN_THRESHOLD = 26
-const SUB_TEXT_SIZE_MAX_THRESHOLD = 40
-
-// multiply text size with this factor
-// to get an appropriate icon padding
-const ICON_PADDING_FACTOR = 0.3
-
-// Compute text width before rendering
-// Not exactly precise but close enough
-const getTextWidth = (text, font) => {
- const canvas = document.createElement('canvas')
- const context = canvas.getContext('2d')
- context.font = font
- return Math.round(
- context.measureText(text).width * ACTUAL_TEXT_WIDTH_FACTOR
- )
-}
-
-const getTextHeightForNumbers = (textSize) =>
- textSize * ACTUAL_NUMBER_HEIGHT_FACTOR
-
-const getIconPadding = (textSize) => Math.round(textSize * ICON_PADDING_FACTOR)
-
-const getTextSize = (
- formattedValue,
- containerWidth,
- containerHeight,
- showIcon
-) => {
- let size = Math.min(
- Math.round(containerHeight * TEXT_SIZE_CONTAINER_HEIGHT_FACTOR),
- TEXT_SIZE_MAX_THRESHOLD
- )
-
- const widthThreshold = Math.round(
- containerWidth * TEXT_WIDTH_CONTAINER_WIDTH_FACTOR
- )
-
- const textWidth =
- getTextWidth(formattedValue, `${size}px Roboto`) +
- (showIcon ? getIconPadding(size) : 0)
-
- if (textWidth > widthThreshold) {
- size = Math.round(size * (widthThreshold / textWidth))
- }
-
- return size
-}
-
-const generateValueSVG = ({
- formattedValue,
- subText,
- valueColor,
- textColor,
- icon,
- noData,
- containerWidth,
- containerHeight,
- topMargin = 0,
-}) => {
- const showIcon = icon && formattedValue !== noData.text
-
- const textSize = getTextSize(
- formattedValue,
- containerWidth,
- containerHeight,
- showIcon
- )
-
- const textWidth = getTextWidth(formattedValue, `${textSize}px Roboto`)
-
- const iconSize = textSize
-
- const subTextSize =
- textSize * SUB_TEXT_SIZE_FACTOR > SUB_TEXT_SIZE_MAX_THRESHOLD
- ? SUB_TEXT_SIZE_MAX_THRESHOLD
- : textSize * SUB_TEXT_SIZE_FACTOR < SUB_TEXT_SIZE_MIN_THRESHOLD
- ? SUB_TEXT_SIZE_MIN_THRESHOLD
- : textSize * SUB_TEXT_SIZE_FACTOR
-
- const svgValue = document.createElementNS(svgNS, 'svg')
- svgValue.setAttribute('viewBox', `0 0 ${containerWidth} ${containerHeight}`)
- svgValue.setAttribute('width', '50%')
- svgValue.setAttribute('height', '50%')
- svgValue.setAttribute('x', '50%')
- svgValue.setAttribute('y', '50%')
- svgValue.setAttribute('style', 'overflow: visible')
-
- let fillColor = colors.grey900
-
- if (valueColor) {
- fillColor = valueColor
- } else if (formattedValue === noData.text) {
- fillColor = colors.grey600
- }
-
- // show icon if configured in maintenance app
- if (showIcon) {
- // embed icon to allow changing color
- // (elements with fill need to use "currentColor" for this to work)
- const iconSvgNode = document.createElementNS(svgNS, 'svg')
- iconSvgNode.setAttribute('viewBox', '0 0 48 48')
- iconSvgNode.setAttribute('width', iconSize)
- iconSvgNode.setAttribute('height', iconSize)
- iconSvgNode.setAttribute('y', (iconSize / 2 - topMargin / 2) * -1)
- iconSvgNode.setAttribute(
- 'x',
- `-${(iconSize + getIconPadding(textSize) + textWidth) / 2}`
- )
- iconSvgNode.setAttribute('style', `color: ${fillColor}`)
- iconSvgNode.setAttribute('data-test', 'visualization-icon')
-
- const parser = new DOMParser()
- const svgIconDocument = parser.parseFromString(icon, 'image/svg+xml')
-
- Array.from(svgIconDocument.documentElement.children).forEach((node) =>
- iconSvgNode.appendChild(node)
- )
-
- svgValue.appendChild(iconSvgNode)
- }
-
- const letterSpacing = Math.round(textSize * LETTER_SPACING_TEXT_SIZE_FACTOR)
-
- const textNode = document.createElementNS(svgNS, 'text')
- textNode.setAttribute('font-size', textSize)
- textNode.setAttribute('font-weight', '300')
- textNode.setAttribute(
- 'letter-spacing',
- letterSpacing < LETTER_SPACING_MIN_THRESHOLD
- ? LETTER_SPACING_MIN_THRESHOLD
- : letterSpacing > LETTER_SPACING_MAX_THRESHOLD
- ? LETTER_SPACING_MAX_THRESHOLD
- : letterSpacing
- )
- textNode.setAttribute('text-anchor', 'middle')
- textNode.setAttribute(
- 'x',
- showIcon ? `${(iconSize + getIconPadding(textSize)) / 2}` : 0
- )
- textNode.setAttribute(
- 'y',
- topMargin / 2 + getTextHeightForNumbers(textSize) / 2
- )
- textNode.setAttribute('fill', fillColor)
- textNode.setAttribute('data-test', 'visualization-primary-value')
-
- textNode.appendChild(document.createTextNode(formattedValue))
-
- svgValue.appendChild(textNode)
-
- if (subText) {
- const subTextNode = document.createElementNS(svgNS, 'text')
- subTextNode.setAttribute('text-anchor', 'middle')
- subTextNode.setAttribute('font-size', subTextSize)
- subTextNode.setAttribute('y', iconSize / 2 + topMargin / 2)
- subTextNode.setAttribute('dy', subTextSize * 1.7)
- subTextNode.setAttribute('fill', textColor)
- subTextNode.appendChild(document.createTextNode(subText))
-
- svgValue.appendChild(subTextNode)
- }
-
- return svgValue
-}
-
-const generateDashboardItem = (
- config,
- {
- svgContainer,
- width,
- height,
- valueColor,
- titleColor,
- backgroundColor,
- noData,
- icon,
- }
-) => {
- svgContainer.appendChild(
- generateValueSVG({
- formattedValue: config.formattedValue,
- subText: config.subText,
- valueColor,
- textColor: titleColor,
- noData,
- icon,
- containerWidth: width,
- containerHeight: height,
- })
- )
-
- const container = document.createElement('div')
- container.setAttribute(
- 'style',
- `display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; padding-top: 8px; ${
- backgroundColor ? `background-color:${backgroundColor};` : ''
- }`
- )
-
- const titleStyle = `padding: 0 8px; text-align: center; font-size: 12px; color: ${
- titleColor || '#666'
- };`
-
- const title = document.createElement('span')
- title.setAttribute('style', titleStyle)
- if (config.title) {
- title.appendChild(document.createTextNode(config.title))
-
- container.appendChild(title)
- }
-
- if (config.subtitle) {
- const subtitle = document.createElement('span')
- subtitle.setAttribute('style', titleStyle + ' margin-top: 4px;')
-
- subtitle.appendChild(document.createTextNode(config.subtitle))
-
- container.appendChild(subtitle)
- }
-
- container.appendChild(svgContainer)
-
- return container
-}
-
-const getTextAnchorFromTextAlign = (textAlign) => {
- switch (textAlign) {
- default:
- case TEXT_ALIGN_LEFT:
- return 'start'
- case TEXT_ALIGN_CENTER:
- return 'middle'
- case TEXT_ALIGN_RIGHT:
- return 'end'
- }
-}
-
-const getXFromTextAlign = (textAlign) => {
- switch (textAlign) {
- default:
- case TEXT_ALIGN_LEFT:
- return '1%'
- case TEXT_ALIGN_CENTER:
- return '50%'
- case TEXT_ALIGN_RIGHT:
- return '99%'
- }
-}
-
-const generateDVItem = (
- config,
- {
- svgContainer,
- width,
- height,
- valueColor,
- noData,
- backgroundColor,
- titleColor,
- fontStyle,
- icon,
- }
-) => {
- if (backgroundColor) {
- svgContainer.setAttribute(
- 'style',
- `background-color: ${backgroundColor};`
- )
-
- const background = document.createElementNS(svgNS, 'rect')
- background.setAttribute('width', '100%')
- background.setAttribute('height', '100%')
- background.setAttribute('fill', backgroundColor)
- svgContainer.appendChild(background)
- }
-
- const svgWrapper = document.createElementNS(svgNS, 'svg')
-
- // title
- const title = document.createElementNS(svgNS, 'text')
-
- const titleFontStyle = mergeFontStyleWithDefault(
- fontStyle && fontStyle[FONT_STYLE_VISUALIZATION_TITLE],
- FONT_STYLE_VISUALIZATION_TITLE
- )
-
- const titleYPosition =
- TOP_MARGIN_FIXED +
- parseInt(titleFontStyle[FONT_STYLE_OPTION_FONT_SIZE]) +
- 'px'
-
- const titleAttributes = {
- x: getXFromTextAlign(titleFontStyle[FONT_STYLE_OPTION_TEXT_ALIGN]),
- y: titleYPosition,
- 'text-anchor': getTextAnchorFromTextAlign(
- titleFontStyle[FONT_STYLE_OPTION_TEXT_ALIGN]
- ),
- 'font-size': `${titleFontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
- 'font-weight': titleFontStyle[FONT_STYLE_OPTION_BOLD]
- ? FONT_STYLE_OPTION_BOLD
- : 'normal',
- 'font-style': titleFontStyle[FONT_STYLE_OPTION_ITALIC]
- ? FONT_STYLE_OPTION_ITALIC
- : 'normal',
- 'data-test': 'visualization-title',
- fill:
- titleColor &&
- titleFontStyle[FONT_STYLE_OPTION_TEXT_COLOR] ===
- defaultFontStyle[FONT_STYLE_VISUALIZATION_TITLE][
- FONT_STYLE_OPTION_TEXT_COLOR
- ]
- ? titleColor
- : titleFontStyle[FONT_STYLE_OPTION_TEXT_COLOR],
- }
-
- Object.entries(titleAttributes).forEach(([key, value]) =>
- title.setAttribute(key, value)
- )
-
- if (config.title) {
- title.appendChild(document.createTextNode(config.title))
- svgWrapper.appendChild(title)
- }
-
- // subtitle
- const subtitle = document.createElementNS(svgNS, 'text')
-
- const subtitleFontStyle = mergeFontStyleWithDefault(
- fontStyle && fontStyle[FONT_STYLE_VISUALIZATION_SUBTITLE],
- FONT_STYLE_VISUALIZATION_SUBTITLE
- )
-
- const subtitleAttributes = {
- x: getXFromTextAlign(subtitleFontStyle[FONT_STYLE_OPTION_TEXT_ALIGN]),
- y: titleYPosition,
- dy: `${subtitleFontStyle[FONT_STYLE_OPTION_FONT_SIZE] + 10}`,
- 'text-anchor': getTextAnchorFromTextAlign(
- subtitleFontStyle[FONT_STYLE_OPTION_TEXT_ALIGN]
- ),
- 'font-size': `${subtitleFontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
- 'font-weight': subtitleFontStyle[FONT_STYLE_OPTION_BOLD]
- ? FONT_STYLE_OPTION_BOLD
- : 'normal',
- 'font-style': subtitleFontStyle[FONT_STYLE_OPTION_ITALIC]
- ? FONT_STYLE_OPTION_ITALIC
- : 'normal',
- fill:
- titleColor &&
- subtitleFontStyle[FONT_STYLE_OPTION_TEXT_COLOR] ===
- defaultFontStyle[FONT_STYLE_VISUALIZATION_SUBTITLE][
- FONT_STYLE_OPTION_TEXT_COLOR
- ]
- ? titleColor
- : subtitleFontStyle[FONT_STYLE_OPTION_TEXT_COLOR],
- 'data-test': 'visualization-subtitle',
- }
-
- Object.entries(subtitleAttributes).forEach(([key, value]) =>
- subtitle.setAttribute(key, value)
- )
-
- if (config.subtitle) {
- subtitle.appendChild(document.createTextNode(config.subtitle))
- svgWrapper.appendChild(subtitle)
- }
-
- svgContainer.appendChild(svgWrapper)
-
- svgContainer.appendChild(
- generateValueSVG({
- formattedValue: config.formattedValue,
- subText: config.subText,
- valueColor,
- textColor: titleColor,
- noData,
- icon,
- containerWidth: width,
- containerHeight: height,
- topMargin:
- TOP_MARGIN_FIXED +
- ((config.title
- ? parseInt(title.getAttribute('font-size'))
- : 0) +
- (config.subtitle
- ? parseInt(subtitle.getAttribute('font-size'))
- : 0)) *
- 2.5,
- })
- )
-
- return svgContainer
-}
-
-const shouldUseContrastColor = (inputColor = '') => {
- // based on https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
- var color =
- inputColor.charAt(0) === '#' ? inputColor.substring(1, 7) : inputColor
- var r = parseInt(color.substring(0, 2), 16) // hexToR
- var g = parseInt(color.substring(2, 4), 16) // hexToG
- var b = parseInt(color.substring(4, 6), 16) // hexToB
- var uicolors = [r / 255, g / 255, b / 255]
- var c = uicolors.map((col) => {
- if (col <= 0.03928) {
- return col / 12.92
- }
- return Math.pow((col + 0.055) / 1.055, 2.4)
- })
- var L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
- return L <= 0.179
-}
-
-export default function (
- config,
- parentEl,
- { dashboard, legendSets, fontStyle, noData, legendOptions, icon }
-) {
- const legendSet = legendOptions && legendSets[0]
- const legendColor =
- legendSet && getColorByValueFromLegendSet(legendSet, config.value)
- let valueColor, titleColor, backgroundColor
- if (legendColor) {
- if (legendOptions.style === LEGEND_DISPLAY_STYLE_FILL) {
- backgroundColor = legendColor
- valueColor = titleColor =
- shouldUseContrastColor(legendColor) && colors.white
- } else {
- valueColor = legendColor
- }
- }
-
- parentEl.style.overflow = 'hidden'
- parentEl.style.display = 'flex'
- parentEl.style.justifyContent = 'center'
-
- const parentElBBox = parentEl.getBoundingClientRect()
- const width = parentElBBox.width
- const height = parentElBBox.height
-
- const svgContainer = document.createElementNS(svgNS, 'svg')
- svgContainer.setAttribute('xmlns', svgNS)
- svgContainer.setAttribute('viewBox', `0 0 ${width} ${height}`)
- svgContainer.setAttribute('width', dashboard ? '100%' : width)
- svgContainer.setAttribute('height', dashboard ? '100%' : height)
- svgContainer.setAttribute('data-test', 'visualization-container')
-
- if (dashboard) {
- parentEl.style.borderRadius = '3px'
-
- return generateDashboardItem(config, {
- svgContainer,
- width,
- height,
- valueColor,
- backgroundColor,
- noData,
- icon,
- ...(legendOptions.style === LEGEND_DISPLAY_STYLE_FILL &&
- legendColor &&
- shouldUseContrastColor(legendColor)
- ? { titleColor: colors.white }
- : {}),
- })
- } else {
- parentEl.style.height = `100%`
-
- return generateDVItem(config, {
- svgContainer,
- width,
- height,
- valueColor,
- backgroundColor,
- titleColor,
- noData,
- icon,
- fontStyle,
- })
- }
-}
diff --git a/src/visualizations/config/generators/highcharts/index.js b/src/visualizations/config/generators/highcharts/index.js
index ca8ad2b3d..3620e81f5 100644
--- a/src/visualizations/config/generators/highcharts/index.js
+++ b/src/visualizations/config/generators/highcharts/index.js
@@ -3,16 +3,24 @@ import HM from 'highcharts/highcharts-more'
import HB from 'highcharts/modules/boost'
import HE from 'highcharts/modules/exporting'
import HNDTD from 'highcharts/modules/no-data-to-display'
+import HOE from 'highcharts/modules/offline-exporting'
import HPF from 'highcharts/modules/pattern-fill'
import HSG from 'highcharts/modules/solid-gauge'
+import PEBFP from './pdfExportBugFixPlugin/index.js'
// apply
HM(H)
HSG(H)
HNDTD(H)
HE(H)
+HOE(H)
HPF(H)
HB(H)
+PEBFP(H)
+
+/* Whitelist some additional SVG attributes here. Without this,
+ * the PDF export for the SingleValue visualization breaks. */
+H.AST.allowedAttributes.push('fill-rule', 'clip-rule')
function drawLegendSymbolWrap() {
const pick = H.pick
@@ -20,6 +28,8 @@ function drawLegendSymbolWrap() {
H.seriesTypes.column.prototype,
'drawLegendSymbol',
function (proceed, legend, item) {
+ const legendItem = item.legendItem
+
if (this.options.legendSet?.legends?.length) {
const ys = legend.baseline - legend.symbolHeight + 1, // y start
x = legend.symbolWidth / 2 > 8 ? legend.symbolWidth / 2 : 8, // x start
@@ -32,7 +42,7 @@ function drawLegendSymbolWrap() {
.attr({
fill: legends[legends.length >= 5 ? 1 : 0].color,
})
- .add(this.legendGroup)
+ .add(legendItem.group)
this.chart.renderer
.path(['M', x, ye, 'A', 1, 1, 0, 0, 0, x, ys, 'V', ye])
.attr({
@@ -42,13 +52,14 @@ function drawLegendSymbolWrap() {
: legends.length - 1
].color,
})
- .add(this.legendGroup)
+ .add(legendItem.group)
} else {
var options = legend.options,
symbolHeight = legend.symbolHeight,
square = options.squareSymbol,
symbolWidth = square ? symbolHeight : legend.symbolWidth
- item.legendSymbol = this.chart.renderer
+
+ legendItem.symbol = this.chart.renderer
.rect(
square ? (legend.symbolWidth - symbolHeight) / 2 : 0,
legend.baseline - symbolHeight + 1,
@@ -60,7 +71,7 @@ function drawLegendSymbolWrap() {
.attr({
zIndex: 3,
})
- .add(item.legendGroup)
+ .add(legendItem.group)
}
}
)
@@ -72,7 +83,6 @@ export default function (config, el) {
// silence warning about accessibility
config.accessibility = { enabled: false }
-
if (config.lang) {
H.setOptions({
lang: config.lang,
diff --git a/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js
new file mode 100644
index 000000000..7b4899cde
--- /dev/null
+++ b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js
@@ -0,0 +1,7 @@
+import nonASCIIFontBugfix from './nonASCIIFont.js'
+import textShadowBugFix from './textShadow.js'
+
+export default function (H) {
+ textShadowBugFix(H)
+ nonASCIIFontBugfix(H)
+}
diff --git a/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js
new file mode 100644
index 000000000..d2c8d9835
--- /dev/null
+++ b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js
@@ -0,0 +1,9 @@
+/* This is a workaround for https://github.com/highcharts/highcharts/issues/22008
+ * We add some transparent text in a non-ASCII script to the chart to prevent
+ * the chart from being exported in a serif font */
+
+export default function (H) {
+ H.addEvent(H.Chart, 'load', function () {
+ this.renderer.text('모', 20, 20).attr({ opacity: 0 }).add()
+ })
+}
diff --git a/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js
new file mode 100644
index 000000000..21a96e1a5
--- /dev/null
+++ b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js
@@ -0,0 +1,308 @@
+/* This plugin was provided by HighCharts support and resolves an issue with label
+ * text that has a white outline, such as the one we use for stacked bar charts.
+ * For example: "ANC: 1-4 visits by districts this year (stacked)"
+ * This issue has actually been resolved in HighCharts v11, so once we have upgraded
+ * to that version, this plugin can be removed. */
+
+export default function (H) {
+ const { AST, defaultOptions, downloadURL } = H,
+ { ajax } = H.HttpUtilities,
+ doc = document,
+ win = window,
+ OfflineExporting =
+ H._modules['Extensions/OfflineExporting/OfflineExporting.js'],
+ { getScript, svgToPdf, imageToDataUrl, svgToDataUrl } = OfflineExporting
+
+ H.wrap(
+ OfflineExporting,
+ 'downloadSVGLocal',
+ function (proceed, svg, options, failCallback, successCallback) {
+ var dummySVGContainer = doc.createElement('div'),
+ imageType = options.type || 'image/png',
+ filename =
+ (options.filename || 'chart') +
+ '.' +
+ (imageType === 'image/svg+xml'
+ ? 'svg'
+ : imageType.split('/')[1]),
+ scale = options.scale || 1
+ var svgurl,
+ blob,
+ finallyHandler,
+ libURL = options.libURL || defaultOptions.exporting.libURL,
+ objectURLRevoke = true,
+ pdfFont = options.pdfFont
+ // Allow libURL to end with or without fordward slash
+ libURL = libURL.slice(-1) !== '/' ? libURL + '/' : libURL
+ /*
+ * Detect if we need to load TTF fonts for the PDF, then load them and
+ * proceed.
+ *
+ * @private
+ */
+ var loadPdfFonts = function (svgElement, callback) {
+ var hasNonASCII = function (s) {
+ return (
+ // eslint-disable-next-line no-control-regex
+ /[^\u0000-\u007F\u200B]+/.test(s)
+ )
+ }
+ // Register an event in order to add the font once jsPDF is
+ // initialized
+ var addFont = function (variant, base64) {
+ win.jspdf.jsPDF.API.events.push([
+ 'initialized',
+ function () {
+ this.addFileToVFS(variant, base64)
+ this.addFont(variant, 'HighchartsFont', variant)
+ if (!this.getFontList().HighchartsFont) {
+ this.setFont('HighchartsFont')
+ }
+ },
+ ])
+ }
+ // If there are no non-ASCII characters in the SVG, do not use
+ // bother downloading the font files
+ if (pdfFont && !hasNonASCII(svgElement.textContent || '')) {
+ pdfFont = void 0
+ }
+ // Add new font if the URL is declared, #6417.
+ var variants = ['normal', 'italic', 'bold', 'bolditalic']
+ // Shift the first element off the variants and add as a font.
+ // Then asynchronously trigger the next variant until calling the
+ // callback when the variants are empty.
+ var normalBase64
+ var shiftAndLoadVariant = function () {
+ var variant = variants.shift()
+ // All variants shifted and possibly loaded, proceed
+ if (!variant) {
+ return callback()
+ }
+ var url = pdfFont && pdfFont[variant]
+ if (url) {
+ ajax({
+ url: url,
+ responseType: 'blob',
+ success: function (data, xhr) {
+ var reader = new FileReader()
+ reader.onloadend = function () {
+ if (typeof this.result === 'string') {
+ var base64 = this.result.split(',')[1]
+ addFont(variant, base64)
+ if (variant === 'normal') {
+ normalBase64 = base64
+ }
+ }
+ shiftAndLoadVariant()
+ }
+ reader.readAsDataURL(xhr.response)
+ },
+ error: shiftAndLoadVariant,
+ })
+ } else {
+ // For other variants, fall back to normal text weight/style
+ if (normalBase64) {
+ addFont(variant, normalBase64)
+ }
+ shiftAndLoadVariant()
+ }
+ }
+ shiftAndLoadVariant()
+ }
+ /*
+ * @private
+ */
+ var downloadPDF = function () {
+ AST.setElementHTML(dummySVGContainer, svg)
+ var textElements =
+ dummySVGContainer.getElementsByTagName('text'),
+ // Copy style property to element from parents if it's not
+ // there. Searches up hierarchy until it finds prop, or hits the
+ // chart container.
+ setStylePropertyFromParents = function (el, propName) {
+ var curParent = el
+ while (curParent && curParent !== dummySVGContainer) {
+ if (curParent.style[propName]) {
+ el.style[propName] = curParent.style[propName]
+ break
+ }
+ curParent = curParent.parentNode
+ }
+ }
+ var titleElements,
+ outlineElements
+ // Workaround for the text styling. Making sure it does pick up
+ // settings for parent elements.
+ ;[].forEach.call(textElements, function (el) {
+ // Workaround for the text styling. making sure it does pick up
+ // the root element
+ ;['font-family', 'font-size'].forEach(function (property) {
+ setStylePropertyFromParents(el, property)
+ })
+ el.style.fontFamily =
+ pdfFont && pdfFont.normal
+ ? // Custom PDF font
+ 'HighchartsFont'
+ : // Generic font (serif, sans-serif etc)
+ String(
+ el.style.fontFamily &&
+ el.style.fontFamily.split(' ').splice(-1)
+ )
+ // Workaround for plotband with width, removing title from text
+ // nodes
+ titleElements = el.getElementsByTagName('title')
+ ;[].forEach.call(titleElements, function (titleElement) {
+ el.removeChild(titleElement)
+ })
+
+ // Remove all .highcharts-text-outline elements, #17170
+ outlineElements = el.getElementsByClassName(
+ 'highcharts-text-outline'
+ )
+ while (outlineElements.length > 0) {
+ const outline = outlineElements[0]
+ if (outline.parentNode) {
+ outline.parentNode.removeChild(outline)
+ }
+ }
+ })
+ var svgNode = dummySVGContainer.querySelector('svg')
+ if (svgNode) {
+ loadPdfFonts(svgNode, function () {
+ svgToPdf(svgNode, 0, function (pdfData) {
+ try {
+ downloadURL(pdfData, filename)
+ if (successCallback) {
+ successCallback()
+ }
+ } catch (e) {
+ failCallback(e)
+ }
+ })
+ })
+ }
+ }
+ // Initiate download depending on file type
+ if (imageType === 'image/svg+xml') {
+ // SVG download. In this case, we want to use Microsoft specific
+ // Blob if available
+ try {
+ if (typeof win.navigator.msSaveOrOpenBlob !== 'undefined') {
+ // eslint-disable-next-line no-undef
+ blob = new MSBlobBuilder()
+ blob.append(svg)
+ svgurl = blob.getBlob('image/svg+xml')
+ } else {
+ svgurl = svgToDataUrl(svg)
+ }
+ downloadURL(svgurl, filename)
+ if (successCallback) {
+ successCallback()
+ }
+ } catch (e) {
+ failCallback(e)
+ }
+ } else if (imageType === 'application/pdf') {
+ if (win.jspdf && win.jspdf.jsPDF) {
+ downloadPDF()
+ } else {
+ // Must load pdf libraries first. // Don't destroy the object
+ // URL yet since we are doing things asynchronously. A cleaner
+ // solution would be nice, but this will do for now.
+ objectURLRevoke = true
+ getScript(libURL + 'jspdf.js', function () {
+ getScript(libURL + 'svg2pdf.js', downloadPDF)
+ })
+ }
+ } else {
+ // PNG/JPEG download - create bitmap from SVG
+ svgurl = svgToDataUrl(svg)
+ finallyHandler = function () {
+ try {
+ OfflineExporting.domurl.revokeObjectURL(svgurl)
+ } catch (e) {
+ // Ignore
+ }
+ }
+ // First, try to get PNG by rendering on canvas
+ imageToDataUrl(
+ svgurl,
+ imageType,
+ {},
+ scale,
+ function (imageURL) {
+ // Success
+ try {
+ downloadURL(imageURL, filename)
+ if (successCallback) {
+ successCallback()
+ }
+ } catch (e) {
+ failCallback(e)
+ }
+ },
+ function () {
+ // Failed due to tainted canvas
+ // Create new and untainted canvas
+ var canvas = doc.createElement('canvas'),
+ ctx = canvas.getContext('2d'),
+ imageWidth =
+ svg.match(
+ // eslint-disable-next-line no-useless-escape
+ /^