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"