diff --git a/i18n/en.pot b/i18n/en.pot
index 52645c02d..bfd246658 100644
--- a/i18n/en.pot
+++ b/i18n/en.pot
@@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
-"POT-Creation-Date: 2023-07-06T08:30:33.216Z\n"
-"PO-Revision-Date: 2023-07-06T08:30:33.216Z\n"
+"POT-Creation-Date: 2023-09-27T14:15:13.876Z\n"
+"PO-Revision-Date: 2023-09-27T14:15:13.876Z\n"
msgid "view only"
msgstr "view only"
@@ -374,6 +374,9 @@ msgstr "Hide interpretation"
msgid "Write an interpretation"
msgstr "Write an interpretation"
+msgid "Other people viewing this interpretation in the future may see more data."
+msgstr "Other people viewing this interpretation in the future may see more data."
+
msgid "Post interpretation"
msgstr "Post interpretation"
diff --git a/src/__demo__/InterpretationsUnit.stories.js b/src/__demo__/InterpretationsUnit.stories.js
new file mode 100644
index 000000000..f1c786f57
--- /dev/null
+++ b/src/__demo__/InterpretationsUnit.stories.js
@@ -0,0 +1,51 @@
+import { CustomDataProvider } from '@dhis2/app-runtime'
+import { storiesOf } from '@storybook/react'
+import React from 'react'
+import { InterpretationsUnit } from '../components/Interpretations/InterpretationsUnit/index.js'
+
+storiesOf('IntepretationsUnit', module).add('Default', () => {
+ return (
+
+
+
+ )
+})
+
+storiesOf('IntepretationsUnit', module).add(
+ 'With no time dimensions warning',
+ () => {
+ return (
+
+
+
+ )
+ }
+)
diff --git a/src/components/AboutAOUnit/AboutAOUnit.js b/src/components/AboutAOUnit/AboutAOUnit.js
index 0fb7fb78e..76002a453 100644
--- a/src/components/AboutAOUnit/AboutAOUnit.js
+++ b/src/components/AboutAOUnit/AboutAOUnit.js
@@ -1,4 +1,8 @@
-import { useDataQuery, useDataMutation } from '@dhis2/app-runtime'
+import {
+ useDataQuery,
+ useDataMutation,
+ useTimeZoneConversion,
+} from '@dhis2/app-runtime'
import i18n from '@dhis2/d2-i18n'
import { Parser as RichTextParser } from '@dhis2/d2-ui-rich-text'
import {
@@ -57,6 +61,7 @@ const getUnsubscribeMutation = (type, id) => ({
const AboutAOUnit = forwardRef(({ type, id, renderId }, ref) => {
const [isExpanded, setIsExpanded] = useState(true)
+ const { fromServerDate } = useTimeZoneConversion()
const queries = useMemo(() => getQueries(type), [type])
@@ -208,7 +213,7 @@ const AboutAOUnit = forwardRef(({ type, id, renderId }, ref) => {
{i18n.t('Last updated {{time}}', {
time: moment(
- data.ao.lastUpdated
+ fromServerDate(data.ao.lastUpdated)
).fromNow(),
})}
@@ -219,7 +224,9 @@ const AboutAOUnit = forwardRef(({ type, id, renderId }, ref) => {
'Created {{time}} by {{author}}',
{
time: moment(
- data.ao.created
+ fromServerDate(
+ data.ao.created
+ )
).fromNow(),
author: data.ao.createdBy
.displayName,
@@ -227,7 +234,9 @@ const AboutAOUnit = forwardRef(({ type, id, renderId }, ref) => {
)
: i18n.t('Created {{time}}', {
time: moment(
- data.ao.created
+ fromServerDate(
+ data.ao.created
+ )
).fromNow(),
})}
diff --git a/src/components/Interpretations/InterpretationModal/InterpretationModal.js b/src/components/Interpretations/InterpretationModal/InterpretationModal.js
index 4f02eaefa..5cc5050bd 100644
--- a/src/components/Interpretations/InterpretationModal/InterpretationModal.js
+++ b/src/components/Interpretations/InterpretationModal/InterpretationModal.js
@@ -24,14 +24,14 @@ const modalCSS = css.resolve`
max-width: calc(100vw - 128px) !important;
max-height: calc(100vh - 128px) !important;
width: auto !important;
- height: auto !important;
+ height: calc(100vh - 128px) !important;
overflow-y: hidden;
}
aside.hidden {
display: none;
}
aside > :global(div) > :global(div) {
- max-height: none;
+ height: 100%;
}
`
@@ -39,6 +39,7 @@ function getModalContentCSS(width) {
return css.resolve`
div {
width: ${width}px;
+ overflow-y: visible;
}
`
}
@@ -216,12 +217,14 @@ const InterpretationModal = ({
.container {
display: flex;
flex-direction: column;
+ height: 100%;
}
.row {
display: flex;
flex-direction: row;
gap: 16px;
+ height: 100%;
}
.visualisation-wrap {
@@ -233,7 +236,6 @@ const InterpretationModal = ({
padding-right: ${spacers.dp4};
flex-basis: 300px;
flex-shrink: 0;
- overflow-y: auto;
}
`}
diff --git a/src/components/Interpretations/InterpretationModal/InterpretationThread.js b/src/components/Interpretations/InterpretationModal/InterpretationThread.js
index 03540290d..03686cd7b 100644
--- a/src/components/Interpretations/InterpretationModal/InterpretationThread.js
+++ b/src/components/Interpretations/InterpretationModal/InterpretationThread.js
@@ -1,3 +1,4 @@
+import { useTimeZoneConversion } from '@dhis2/app-runtime'
import { IconClock16, colors } from '@dhis2/ui'
import cx from 'classnames'
import moment from 'moment'
@@ -16,6 +17,7 @@ const InterpretationThread = ({
onThreadUpdated,
downloadMenuComponent: DownloadMenu,
}) => {
+ const { fromServerDate } = useTimeZoneConversion()
const focusRef = useRef()
useEffect(() => {
@@ -33,57 +35,57 @@ const InterpretationThread = ({
return (
-
-
-
- {moment(interpretation.created).format('LLL')}
-
- {DownloadMenu && (
-
- )}
-
-
focusRef.current?.focus()
- : null
- }
- onUpdated={() => onThreadUpdated(true)}
- onDeleted={onInterpretationDeleted}
- isInThread={true}
- />
-
- {interpretation.comments.map((comment) => (
-
- ))}
-
- {interpretationAccess.comment && (
-
+
+ {moment(fromServerDate(interpretation.created)).format('LLL')}
+
+ {DownloadMenu && (
+
+ )}
+
+
focusRef.current?.focus()
+ : null
+ }
+ onUpdated={() => onThreadUpdated(true)}
+ onDeleted={onInterpretationDeleted}
+ isInThread={true}
+ />
+
+ {interpretation.comments.map((comment) => (
+ onThreadUpdated(true)}
- focusRef={focusRef}
+ onThreadUpdated={onThreadUpdated}
+ canComment={interpretationAccess.comment}
/>
- )}
+ ))}
+ {interpretationAccess.comment && (
+
onThreadUpdated(true)}
+ focusRef={focusRef}
+ />
+ )}
-
-)
+ .footer {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: ${spacers.dp8};
+ }
+ `}
+
+ )
+}
Message.propTypes = {
children: PropTypes.node.isRequired,
diff --git a/src/components/Interpretations/common/RichTextEditor/RichTextEditor.js b/src/components/Interpretations/common/RichTextEditor/RichTextEditor.js
index ba2eba9fe..e8ad9216d 100644
--- a/src/components/Interpretations/common/RichTextEditor/RichTextEditor.js
+++ b/src/components/Interpretations/common/RichTextEditor/RichTextEditor.js
@@ -192,7 +192,7 @@ Toolbar.propTypes = {
export const RichTextEditor = forwardRef(
(
- { value, disabled, inputPlaceholder, onChange, errorText },
+ { value, disabled, inputPlaceholder, onChange, errorText, helpText },
externalRef
) => {
const [previewMode, setPreviewMode] = useState(false)
@@ -234,7 +234,11 @@ export const RichTextEditor = forwardRef(
{value}
) : (
-
+
{
describe('getInterpretationAccess', () => {
it('returns true for all accesses for superuser', () => {
@@ -113,6 +128,228 @@ describe('interpretation and comment access', () => {
delete: false,
})
})
+
+ it('throws an error for all accesses when no currentUser provided', () => {
+ const interpretation = {
+ access: {
+ write: false,
+ manage: false,
+ },
+ createdBy: userJane,
+ }
+
+ expect(() => getInterpretationAccess(interpretation)).toThrow(
+ '"hasAuthority" requires "authorities" to be an array or set of authorities (strings)'
+ )
+ })
+
+ it('throws an error when currentUser is missing authorities', () => {
+ const interpretation = {
+ access: {
+ write: false,
+ manage: false,
+ },
+ createdBy: userJane,
+ }
+
+ expect(() =>
+ getInterpretationAccess(interpretation, {
+ id: 'usernoauthorties',
+ })
+ ).toThrow(
+ '"hasAuthority" requires "authorities" to be an array or set of authorities (strings)'
+ )
+ })
+ })
+
+ describe('getInterpretationAccess using D2.currentUser', () => {
+ it('returns true for all accesses for superuser', () => {
+ const interpretation = {
+ access: {
+ write: true,
+ manage: true,
+ },
+ createdBy: userJoeD2CurrentUser,
+ }
+
+ expect(
+ getInterpretationAccess(interpretation, superuserD2CurrentUser)
+ ).toMatchObject({
+ share: true,
+ comment: true,
+ edit: true,
+ delete: true,
+ })
+ })
+ it('returns true for all accesses for creator', () => {
+ const interpretation = {
+ access: {
+ write: true,
+ manage: true,
+ },
+ createdBy: userJoeD2CurrentUser,
+ }
+
+ expect(
+ getInterpretationAccess(interpretation, userJoeD2CurrentUser)
+ ).toMatchObject({
+ share: true,
+ comment: true,
+ edit: true,
+ delete: true,
+ })
+ })
+
+ it('returns false for edit/delete if user is not creator/superuser', () => {
+ const interpretation = {
+ access: {
+ write: true,
+ manage: true,
+ },
+ createdBy: userJaneD2CurrentUser,
+ }
+
+ expect(
+ getInterpretationAccess(interpretation, userJoeD2CurrentUser)
+ ).toMatchObject({
+ share: true,
+ comment: true,
+ edit: false,
+ delete: false,
+ })
+ })
+
+ it('returns false for comment/edit/delete if user is not creator/superuser and no write access', () => {
+ const interpretation = {
+ access: {
+ write: false,
+ manage: true,
+ },
+ createdBy: userJaneD2CurrentUser,
+ }
+
+ expect(
+ getInterpretationAccess(interpretation, userJoeD2CurrentUser)
+ ).toMatchObject({
+ share: true,
+ comment: false,
+ edit: false,
+ delete: false,
+ })
+ })
+
+ it('returns false for share/comment/edit/delete if user is not creator/superuser and no write or manage access', () => {
+ const interpretation = {
+ access: {
+ write: false,
+ manage: false,
+ },
+ createdBy: userJaneD2CurrentUser,
+ }
+
+ expect(
+ getInterpretationAccess(interpretation, userJoeD2CurrentUser)
+ ).toMatchObject({
+ share: false,
+ comment: false,
+ edit: false,
+ delete: false,
+ })
+ })
+ })
+
+ describe('getCommentAccess using D2.currentUser', () => {
+ it('returns true for all accesses for superuser', () => {
+ const interpretation = {
+ access: {
+ write: true,
+ },
+ }
+
+ const comment = {
+ createdBy: userJoeD2CurrentUser,
+ }
+
+ expect(
+ getCommentAccess(
+ comment,
+ interpretation.access.write,
+ superuserD2CurrentUser
+ )
+ ).toMatchObject({
+ edit: true,
+ delete: true,
+ })
+ })
+
+ it('returns true for all accesses for creator when interpretation has write access', () => {
+ const interpretation = {
+ access: {
+ write: true,
+ },
+ }
+
+ const comment = {
+ createdBy: userJoeD2CurrentUser,
+ }
+
+ expect(
+ getCommentAccess(
+ comment,
+ interpretation.access.write,
+ userJoeD2CurrentUser
+ )
+ ).toMatchObject({
+ edit: true,
+ delete: true,
+ })
+ })
+
+ it('returns true for edit and false for delete for creator when interpretation does not have write access', () => {
+ const interpretation = {
+ access: {
+ write: false,
+ },
+ }
+
+ const comment = {
+ createdBy: userJoeD2CurrentUser,
+ }
+
+ expect(
+ getCommentAccess(
+ comment,
+ interpretation.access.write,
+ userJoeD2CurrentUser
+ )
+ ).toMatchObject({
+ edit: true,
+ delete: false,
+ })
+ })
+
+ it('returns false for edit/delete for user who is not creator or superuser', () => {
+ const interpretation = {
+ access: {
+ write: true,
+ },
+ }
+
+ const comment = {
+ createdBy: userJaneD2CurrentUser,
+ }
+
+ expect(
+ getCommentAccess(
+ comment,
+ interpretation.access.write,
+ userJoeD2CurrentUser
+ )
+ ).toMatchObject({
+ edit: false,
+ delete: false,
+ })
+ })
})
describe('getCommentAccess', () => {
diff --git a/src/components/Interpretations/common/getInterpretationAccess.js b/src/components/Interpretations/common/getInterpretationAccess.js
index d95e82af3..11123bc63 100644
--- a/src/components/Interpretations/common/getInterpretationAccess.js
+++ b/src/components/Interpretations/common/getInterpretationAccess.js
@@ -1,9 +1,33 @@
-const isCreatorOrSuperuser = (object, currentUser) =>
- object?.createdBy.id === currentUser?.id ||
- currentUser?.authorities.has('ALL')
+// For backwards compatibility
+// accept both Set (from the old d2.currentUser object) and array
+const hasAuthority = (authorities, authority) => {
+ if (!authority || typeof authority !== 'string') {
+ throw new Error(
+ `"hasAuthority" requires "authority" to be a populated string but received ${authority}`
+ )
+ }
+ if (
+ !(Array.isArray(authorities) || typeof authorities?.has === 'function')
+ ) {
+ throw new Error(
+ `"hasAuthority" requires "authorities" to be an array or set of authorities (strings)`
+ )
+ }
+
+ return Array.isArray(authorities)
+ ? authorities.includes(authority)
+ : authorities.has(authority)
+}
+
+const isSuperuser = (authorities) => hasAuthority(authorities, 'ALL')
+
+const isCreator = (object, currentUser) =>
+ object?.createdBy.id === currentUser?.id
export const getInterpretationAccess = (interpretation, currentUser) => {
- const canEditDelete = isCreatorOrSuperuser(interpretation, currentUser)
+ const canEditDelete =
+ isCreator(interpretation, currentUser) ||
+ isSuperuser(currentUser?.authorities)
return {
share: interpretation.access.manage,
comment: interpretation.access.write,
@@ -17,7 +41,8 @@ export const getCommentAccess = (
hasInterpretationReplyAccess,
currentUser
) => {
- const canEditDelete = isCreatorOrSuperuser(comment, currentUser)
+ const canEditDelete =
+ isCreator(comment, currentUser) || isSuperuser(currentUser?.authorities)
return {
edit: canEditDelete,
delete: canEditDelete && hasInterpretationReplyAccess,
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/yarn.lock b/yarn.lock
index 5e6d9a3ef..08374ba9f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -15261,14 +15261,7 @@ node-dir@^0.1.10:
dependencies:
minimatch "^3.0.2"
-node-fetch@^2.6.0:
- version "2.6.11"
- resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.11.tgz#cde7fc71deef3131ef80a738919f999e6edfff25"
- integrity sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==
- dependencies:
- whatwg-url "^5.0.0"
-
-node-fetch@^2.6.1, node-fetch@^2.6.7:
+node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7:
version "2.6.12"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba"
integrity sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==
@@ -21044,7 +21037,7 @@ webpack@4.44.2:
watchpack "^1.7.4"
webpack-sources "^1.4.1"
-"webpack@>=4.43.0 <6.0.0":
+"webpack@>=4.43.0 <6.0.0", webpack@^5.41.1:
version "5.88.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.88.1.tgz#21eba01e81bd5edff1968aea726e2fbfd557d3f8"
integrity sha512-FROX3TxQnC/ox4N+3xQoWZzvGXSuscxR32rbzjpXgEzWudJFEJBpdlkkob2ylrv5yzzufD1zph1OoFsLtm6stQ==
@@ -21074,36 +21067,6 @@ webpack@4.44.2:
watchpack "^2.4.0"
webpack-sources "^3.2.3"
-webpack@^5.41.1:
- version "5.88.0"
- resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.88.0.tgz#a07aa2f8e7a64a8f1cec0c6c2e180e3cb34440c8"
- integrity sha512-O3jDhG5e44qIBSi/P6KpcCcH7HD+nYIHVBhdWFxcLOcIGN8zGo5nqF3BjyNCxIh4p1vFdNnreZv2h2KkoAw3lw==
- dependencies:
- "@types/eslint-scope" "^3.7.3"
- "@types/estree" "^1.0.0"
- "@webassemblyjs/ast" "^1.11.5"
- "@webassemblyjs/wasm-edit" "^1.11.5"
- "@webassemblyjs/wasm-parser" "^1.11.5"
- acorn "^8.7.1"
- acorn-import-assertions "^1.9.0"
- browserslist "^4.14.5"
- chrome-trace-event "^1.0.2"
- enhanced-resolve "^5.15.0"
- es-module-lexer "^1.2.1"
- eslint-scope "5.1.1"
- events "^3.2.0"
- glob-to-regexp "^0.4.1"
- graceful-fs "^4.2.9"
- json-parse-even-better-errors "^2.3.1"
- loader-runner "^4.2.0"
- mime-types "^2.1.27"
- neo-async "^2.6.2"
- schema-utils "^3.2.0"
- tapable "^2.1.1"
- terser-webpack-plugin "^5.3.7"
- watchpack "^2.4.0"
- webpack-sources "^3.2.3"
-
websocket-driver@>=0.5.1, websocket-driver@^0.7.4:
version "0.7.4"
resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760"