diff --git a/rdmo/conditions/constants.py b/rdmo/conditions/constants.py new file mode 100644 index 0000000000..025428b2c6 --- /dev/null +++ b/rdmo/conditions/constants.py @@ -0,0 +1,14 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class RelationTypes(models.TextChoices): + RELATION_EQUAL = 'eq', _('is equal to (==)') + RELATION_NOT_EQUAL = 'neq', _('is not equal to (!=)') + RELATION_CONTAINS = 'contains', _('contains') + RELATION_GREATER_THAN = 'gt', _('is greater than (>)') + RELATION_GREATER_THAN_EQUAL = 'gte', _('is greater than or equal to (>=)') + RELATION_LESS_THAN = 'lt', _('is less than (<)') + RELATION_LESS_THAN_EQUAL = 'lte', _('is less than or equal to (<=)') + RELATION_EMPTY = 'empty', _('is empty') + RELATION_NOT_EMPTY = 'notempty', _('is not empty') diff --git a/rdmo/conditions/migrations/0026_alter_condition_relation.py b/rdmo/conditions/migrations/0026_alter_condition_relation.py new file mode 100644 index 0000000000..36909b8414 --- /dev/null +++ b/rdmo/conditions/migrations/0026_alter_condition_relation.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-06-16 09:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conditions', '0025_alter_condition_uri_path'), + ] + + operations = [ + migrations.AlterField( + model_name='condition', + name='relation', + field=models.CharField(choices=[('eq', 'is equal to (==)'), ('neq', 'is not equal to (!=)'), ('contains', 'contains'), ('gt', 'is greater than (>)'), ('gte', 'is greater than or equal to (>=)'), ('lt', 'is less than (<)'), ('lte', 'is less than or equal to (<=)'), ('empty', 'is empty'), ('notempty', 'is not empty')], help_text='The relation this condition is using.', max_length=8, verbose_name='Relation'), + ), + ] diff --git a/rdmo/conditions/models.py b/rdmo/conditions/models.py index 04f1723ce3..34b66979ad 100644 --- a/rdmo/conditions/models.py +++ b/rdmo/conditions/models.py @@ -6,29 +6,10 @@ from rdmo.core.utils import join_url from rdmo.domain.models import Attribute +from .constants import RelationTypes -class Condition(models.Model): - RELATION_EQUAL = 'eq' - RELATION_NOT_EQUAL = 'neq' - RELATION_CONTAINS = 'contains' - RELATION_GREATER_THAN = 'gt' - RELATION_GREATER_THAN_EQUAL = 'gte' - RELATION_LESSER_THAN = 'lt' - RELATION_LESSER_THAN_EQUAL = 'lte' - RELATION_EMPTY = 'empty' - RELATION_NOT_EMPTY = 'notempty' - RELATION_CHOICES = ( - (RELATION_EQUAL, 'is equal to (==)'), - (RELATION_NOT_EQUAL, 'is not equal to (!=)'), - (RELATION_CONTAINS, 'contains'), - (RELATION_GREATER_THAN, 'is greater than (>)'), - (RELATION_GREATER_THAN_EQUAL, 'is greater than or equal (>=)'), - (RELATION_LESSER_THAN, 'is lesser than (<)'), - (RELATION_LESSER_THAN_EQUAL, 'is lesser than or equal (<=)'), - (RELATION_EMPTY, 'is empty'), - (RELATION_NOT_EMPTY, 'is not empty'), - ) +class Condition(models.Model): uri = models.URLField( max_length=800, blank=True, @@ -67,7 +48,7 @@ class Condition(models.Model): help_text=_('The attribute of the value for this condition.') ) relation = models.CharField( - max_length=8, choices=RELATION_CHOICES, + max_length=8, choices=RelationTypes, verbose_name=_('Relation'), help_text=_('The relation this condition is using.') ) @@ -134,31 +115,31 @@ def resolve(self, values, set_prefix=None, set_index=None): set_prefix, set_index = rpartition[0], int(rpartition[2]) return self.resolve(values, set_prefix, set_index) - if self.relation == self.RELATION_EQUAL: + if self.relation == RelationTypes.RELATION_EQUAL: return self._resolve_equal(source_values) - elif self.relation == self.RELATION_NOT_EQUAL: + elif self.relation == RelationTypes.RELATION_NOT_EQUAL: return not self._resolve_equal(source_values) - elif self.relation == self.RELATION_CONTAINS: + elif self.relation == RelationTypes.RELATION_CONTAINS: return self._resolve_contains(source_values) - elif self.relation == self.RELATION_GREATER_THAN: + elif self.relation == RelationTypes.RELATION_GREATER_THAN: return self._resolve_greater_than(source_values) - elif self.relation == self.RELATION_GREATER_THAN_EQUAL: + elif self.relation == RelationTypes.RELATION_GREATER_THAN_EQUAL: return self._resolve_greater_than_equal(source_values) - elif self.relation == self.RELATION_LESSER_THAN: - return self._resolve_lesser_than(source_values) + elif self.relation == RelationTypes.RELATION_LESS_THAN: + return self._resolve_less_than(source_values) - elif self.relation == self.RELATION_LESSER_THAN_EQUAL: - return self._resolve_lesser_than_equal(source_values) + elif self.relation == RelationTypes.RELATION_LESS_THAN_EQUAL: + return self._resolve_less_than_equal(source_values) - elif self.relation == self.RELATION_EMPTY: + elif self.relation == RelationTypes.RELATION_EMPTY: return not self._resolve_not_empty(source_values) - elif self.relation == self.RELATION_NOT_EMPTY: + elif self.relation == RelationTypes.RELATION_NOT_EMPTY: return self._resolve_not_empty(source_values) else: @@ -205,7 +186,7 @@ def _resolve_greater_than_equal(self, values): return False - def _resolve_lesser_than(self, values): + def _resolve_less_than(self, values): for value in values: try: @@ -216,7 +197,7 @@ def _resolve_lesser_than(self, values): return False - def _resolve_lesser_than_equal(self, values): + def _resolve_less_than_equal(self, values): for value in values: try: diff --git a/rdmo/conditions/viewsets.py b/rdmo/conditions/viewsets.py index ec10620e58..609ed0db84 100644 --- a/rdmo/conditions/viewsets.py +++ b/rdmo/conditions/viewsets.py @@ -11,6 +11,7 @@ from rdmo.core.utils import is_truthy, render_to_format from rdmo.core.views import ChoicesViewSet +from .constants import RelationTypes from .models import Condition from .renderers import ConditionRenderer from .serializers.export import ConditionExportSerializer @@ -77,4 +78,4 @@ def get_export_renderer_context(self, request): class RelationViewSet(ChoicesViewSet): permission_classes = (IsAuthenticated, ) - queryset = Condition.RELATION_CHOICES + queryset = RelationTypes.choices diff --git a/rdmo/core/assets/js/hooks/useFormattedDateTime.js b/rdmo/core/assets/js/hooks/useFormattedDateTime.js index 4986a4dfcf..82a2e732af 100644 --- a/rdmo/core/assets/js/hooks/useFormattedDateTime.js +++ b/rdmo/core/assets/js/hooks/useFormattedDateTime.js @@ -1,4 +1,4 @@ -import { format } from 'date-fns' +import { format, parseISO } from 'date-fns' import { de, enUS } from 'date-fns/locale' const getLocaleObject = (language) => { @@ -6,15 +6,21 @@ const getLocaleObject = (language) => { } const FORMAT_STRINGS = { - en: 'MMM d, yyyy, h:mm a', - de: 'd. MMM yyyy, H:mm', + dateTime: { + en: 'MMM d, yyyy, h:mm a', + de: 'd. MMM yyyy, H:mm', + }, + dateOnly: { + en: 'MMM d, yyyy', + de: 'd. MMM yyyy', + }, } -export const useFormattedDateTime = (date, language) => { +export const useFormattedDateTime = (date, language, formatType = 'dateTime') => { const locale = getLocaleObject(language) - const formatString = language === 'de' ? FORMAT_STRINGS.de : FORMAT_STRINGS.en + const formatString = FORMAT_STRINGS[formatType][language] ?? FORMAT_STRINGS[formatType].en - return format(new Date(date), formatString, { locale }) + return format(parseISO(date), formatString, { locale }) } export default useFormattedDateTime diff --git a/rdmo/projects/assets/js/project/actions/actionTypes.js b/rdmo/projects/assets/js/project/actions/actionTypes.js index ab19f12323..a415a96bd0 100644 --- a/rdmo/projects/assets/js/project/actions/actionTypes.js +++ b/rdmo/projects/assets/js/project/actions/actionTypes.js @@ -74,3 +74,7 @@ export const DELETE_PROJECT_VISIBILITY_ERROR = 'DELETE_PROJECT_VISIBILITY_ERROR' export const FETCH_PROJECT_VISIBILITY_INIT = 'FETCH_PROJECT_VISIBILITY_INIT' export const FETCH_PROJECT_VISIBILITY_SUCCESS = 'FETCH_PROJECT_VISIBILITY_SUCCESS' export const FETCH_PROJECT_VISIBILITY_ERROR = 'FETCH_PROJECT_VISIBILITY_ERROR' + +export const UPDATE_PROJECT_TASK_INIT = 'UPDATE_PROJECT_TASK_INIT' +export const UPDATE_PROJECT_TASK_SUCCESS = 'UPDATE_PROJECT_TASK_SUCCESS' +export const UPDATE_PROJECT_TASK_ERROR = 'UPDATE_PROJECT_TASK_ERROR' diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js index 10b6f8c26c..260f82b7de 100644 --- a/rdmo/projects/assets/js/project/actions/projectActions.js +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -222,6 +222,41 @@ export function deleteProjectVisibility() { } } +// project task + +export function updateProjectTask(issueId, data) { + return function (dispatch, getState) { + dispatch(addToPending('updateProjectTask')) + dispatch({ type: actionTypes.UPDATE_PROJECT_TASK_INIT }) + + return ProjectApi.updateProjectTask(projectId, issueId, data) + .then(() => { + const state = getState() + const currentBundle = state.project.project + + return ProjectApi.fetchProjectTasks(projectId) + .then((tasks) => ({ currentBundle, tasks })) + }) + .then(({ currentBundle, tasks }) => { + const updatedBundle = { + ...currentBundle, + tasks, + } + + dispatch(removeFromPending('updateProjectTask')) + dispatch({ + type: actionTypes.UPDATE_PROJECT_SUCCESS, + project: updatedBundle, + }) + }) + .catch((error) => { + dispatch(removeFromPending('updateProjectTask')) + dispatch({ type: actionTypes.UPDATE_PROJECT_TASK_ERROR, error }) + throw error + }) + } +} + // memberships / invites / leave export function fetchProjectInvites() { diff --git a/rdmo/projects/assets/js/project/api/ProjectApi.js b/rdmo/projects/assets/js/project/api/ProjectApi.js index 4e8302b024..2276fd4285 100644 --- a/rdmo/projects/assets/js/project/api/ProjectApi.js +++ b/rdmo/projects/assets/js/project/api/ProjectApi.js @@ -26,6 +26,10 @@ export default class ProjectApi extends BaseApi { return this.get(`/api/v1/projects/projects/${projectId}/issues/`) } + static updateProjectTask(projectId, issueId, data) { + return this.put(`/api/v1/projects/projects/${projectId}/issues/${issueId}/`, data) + } + static fetchProjectMemberships(projectId) { return this.get(`/api/v1/projects/projects/${projectId}/memberships/`) } diff --git a/rdmo/projects/assets/js/project/components/areas/Dashboard.js b/rdmo/projects/assets/js/project/components/areas/Dashboard.js index 48577001c2..617b3efe23 100644 --- a/rdmo/projects/assets/js/project/components/areas/Dashboard.js +++ b/rdmo/projects/assets/js/project/components/areas/Dashboard.js @@ -1,46 +1,345 @@ import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' -import Tooltip from 'rdmo/core/assets/js/_bs53/components/Tooltip' +import { Modal } from 'rdmo/core/assets/js/_bs53/components' +import * as configActions from 'rdmo/core/assets/js/actions/configActions' +import { useFormattedDateTime } from 'rdmo/core/assets/js/hooks' +import { language } from 'rdmo/core/assets/js/utils' +import { baseUrl } from 'rdmo/core/assets/js/utils/meta' -import { TileGrid } from '../helper' +import Select from 'rdmo/core/assets/js/components/forms/Select' + +import { navigateDashboard, updateProjectTask } from '../../actions/projectActions' +import { projectId } from '../../utils/meta' +import { Tile } from '../helper' const Dashboard = () => { - const [tileSize, setTileSize] = useState('normal') - - const toggleSize = () => { - setTileSize((prevSize) => { - if (prevSize === 'compact') return 'normal' - if (prevSize === 'normal') return 'fullWidth' - return 'compact' - }) - } + const dispatch = useDispatch() + const config = useSelector(state => state.config) + const perms = useSelector(state => state.project.project.project.permissions) ?? {} + + const allIssues = useSelector((state) => state.project.project.tasks) ?? [] + /* Show only issues that resolve */ + const issues = allIssues.filter((issue) => issue.resolve === true) + + // Mock dates for testing + // issues.forEach((issue) => { + // issue.dates = [ + // ['2017-04-03', '2017-12-31'], + // ['2017-04-03'], + // ['2017-04-04'], + // ] + // }) - const tiles = [ - { title: 'Tile 1', content:

Content 1

}, - { title: 'Tile 2', content:

Content 2

}, - { title: 'Tile 3', content:

Content 3

}, - { title: 'Tile 4', content:

Content 4

}, - { title: 'Tile 5', content:

Content 5

}, - { title: 'Tile 6', content:

Content 6

}, + const statusOptions = [ + { value: 'open', label: gettext('Open') }, + { value: 'closed', label: gettext('Closed') }, + { value: 'in_progress', label: gettext('In progress') }, ] + const { showClosedTasks, showClosedRecommendations } = config + + const [selectedTaskIssue, setSelectedTaskIssue] = useState(null) + + const isClosed = (issue) => issue.status === 'closed' + const getTaskType = (issue) => issue.task?.task_type + + const stepIssues = issues.filter((issue) => + getTaskType(issue) === 'step' + ).sort((a, b) => a.task.order - b.task.order) + + const activeStepIssue = stepIssues.find((issue) => !isClosed(issue)) + + const taskIssues = issues.filter((issue) => + getTaskType(issue) === 'task' + ) + + // console.log('task issues', taskIssues) + + const visibleTaskIssues = taskIssues + .filter((issue) => showClosedTasks || !isClosed(issue)) + .sort((a, b) => Number(isClosed(a)) - Number(isClosed(b))) + + const recommendationIssues = issues.filter((issue) => + getTaskType(issue) === 'recommendation' + ) + // console.log('recommendation issues', recommendationIssues) + + const visibleRecommendationIssues = recommendationIssues + .filter((issue) => showClosedRecommendations || !isClosed(issue)) + .sort((a, b) => Number(isClosed(a)) - Number(isClosed(b))) + + const guidanceIssues = issues.filter((issue) => + getTaskType(issue) === 'guidance' + ).sort((a, b) => a.task.order - b.task.order) + + const toggleTaskDone = (issueId, currentStatus) => { + dispatch(updateProjectTask(issueId, { + status: currentStatus === 'closed' ? 'open' : 'closed' + })) + } + + const renderDate = (date) => ( + date.map((dateValue) => useFormattedDateTime(dateValue, language, 'dateOnly')).join(' - ') + ) + + const renderTaskIssueTiles = (visibleIssues) => ( +
+ { + visibleIssues.map((issue) => { + const closed = isClosed(issue) + return ( + setSelectedTaskIssue(issue)} + > +
+
+ +
+
+
+ {issue.task.title} +
+ { + issue.dates?.length > 0 && ( +
+ + {renderDate(issue.dates[0])} +
+ ) + } +
+
+
+ ) + }) + } +
+ ) + return (

{gettext('Dashboard')}

+ { + perms.can_view_issue && ( + <> + { + stepIssues.length > 0 && ( + <> +

{gettext('Create your data management plan')}

+
+ { + stepIssues.map((issue, index) => { + const isActiveStep = activeStepIssue?.id === issue.id + return ( + { + dispatch(navigateDashboard({ area: issue.task.task_area })) + if (isActiveStep) { + dispatch(updateProjectTask(issue.id, { status: 'closed'})) + } + } + ) : undefined + } + > +

{issue.task.text}

+
+ ) + }) + } +
+ + ) + } + { + taskIssues.length > 0 && ( + <> +

{gettext('Tasks')}

+ +
+ dispatch(configActions.updateConfig('showClosedTasks', !showClosedTasks))} + /> + +
+ {renderTaskIssueTiles(visibleTaskIssues)} + + ) + } + { + recommendationIssues.length > 0 && ( + <> +

{gettext('Recommendations')}

+ +
+ dispatch(configActions.updateConfig( + 'showClosedRecommendations', + !showClosedRecommendations + )) + } + /> + +
+ {renderTaskIssueTiles(visibleRecommendationIssues)} + + ) + } + { + guidanceIssues.length > 0 && ( + <> +

{gettext('More actions')}

+
+ { + guidanceIssues.map((issue) => ( + dispatch(navigateDashboard({ area: issue.task.task_area })) + ) : undefined + } + size="compact" + > +

{issue.task.text}

+
+ )) + } +
+ + ) + } + { + selectedTaskIssue && ( + setSelectedTaskIssue(null)} + size="modal-lg" + > + {/*

{selectedTaskIssue.id}

*/} +

{selectedTaskIssue.task.text}

+