From 6e65c949005847a4a2e979eb192bc506c73bd4c9 Mon Sep 17 00:00:00 2001 From: Isabelle Lafont Date: Wed, 29 Nov 2023 14:52:02 +0100 Subject: [PATCH 1/4] Frontend - add button and logic to command backend action on event --- .../src/components/organisms/AuthContext.tsx | 9 +++++++++ .../form-sections/HeadSection/index.css | 5 +++++ .../form-sections/HeadSection/index.tsx | 16 +++++++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/organisms/AuthContext.tsx b/frontend/src/components/organisms/AuthContext.tsx index 5705cd461..b0030442a 100644 --- a/frontend/src/components/organisms/AuthContext.tsx +++ b/frontend/src/components/organisms/AuthContext.tsx @@ -13,6 +13,7 @@ type AuthContextType = { login: () => void; logout: () => void; getIsUserAnInstructor: (target_api: string) => boolean; + getIsUserAnAdministrator: () => boolean; }; export const AuthContext = React.createContext({ @@ -22,6 +23,7 @@ export const AuthContext = React.createContext({ login: () => {}, logout: () => {}, getIsUserAnInstructor: (target_api) => false, + getIsUserAnAdministrator: () => false, }); export const useAuth = () => { @@ -110,6 +112,12 @@ export class AuthStore extends React.Component<{ children: React.ReactNode }> { return this.state.user.roles.includes(targetApiInstructorRole); }; + getIsUserAnAdministrator = () => { + const administratorRole = 'administrator'; + + return this.state.user.roles.includes(administratorRole); + }; + render() { const { children }: { children?: React.ReactNode } = this.props; const { user, isLoading, connectionError } = this.state; @@ -123,6 +131,7 @@ export class AuthStore extends React.Component<{ children: React.ReactNode }> { login: this.login, logout: this.logout, getIsUserAnInstructor: this.getIsUserAnInstructor, + getIsUserAnAdministrator: this.getIsUserAnAdministrator, }} > {children} diff --git a/frontend/src/components/organisms/form-sections/HeadSection/index.css b/frontend/src/components/organisms/form-sections/HeadSection/index.css index 0ec7b8b32..5e51476cd 100644 --- a/frontend/src/components/organisms/form-sections/HeadSection/index.css +++ b/frontend/src/components/organisms/form-sections/HeadSection/index.css @@ -7,3 +7,8 @@ border-top: 1px solid var(--border-default-grey); border-bottom: 1px solid var(--border-default-grey); } + +.admin-unarchive-section { + display: flex; + justify-content: space-between; +} diff --git a/frontend/src/components/organisms/form-sections/HeadSection/index.tsx b/frontend/src/components/organisms/form-sections/HeadSection/index.tsx index e0437aba8..a2dce6764 100644 --- a/frontend/src/components/organisms/form-sections/HeadSection/index.tsx +++ b/frontend/src/components/organisms/form-sections/HeadSection/index.tsx @@ -10,6 +10,8 @@ import ActivityFeed from './ActivityFeed'; import './index.css'; import NotificationSubSection from './NotificationSubSection'; import { Event } from '../../../../config'; +import Button from '../../../atoms/hyperTexts/Button'; +import { useAuth } from '../../AuthContext'; export const HeadSection = () => { const { @@ -17,12 +19,24 @@ export const HeadSection = () => { } = useContext(FormContext)!; const { label } = useDataProvider(target_api); + const { getIsUserAnAdministrator } = useAuth(); + + const isUserAnAdministrator = getIsUserAnAdministrator(); return (
<>Vous demandez l’accès à -

{label}

+
+

{label}

+
+ {isUserAnAdministrator && ( + + )} +
+
{id && Habilitation n°{id}} From 754b83712466eac23792bc39561079a7a6089892 Mon Sep 17 00:00:00 2001 From: Isabelle Lafont Date: Thu, 30 Nov 2023 13:03:21 +0100 Subject: [PATCH 2/4] logic to click with call to process-event --- .../molecules/EventButtonList.test.ts | 1 + .../form-sections/HeadSection/index.tsx | 29 ++++++++++++++++++- frontend/src/config/event-configuration.tsx | 13 +++++++++ frontend/src/lib/process-event.ts | 2 +- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/molecules/EventButtonList.test.ts b/frontend/src/components/molecules/EventButtonList.test.ts index 83d51416a..25979048f 100644 --- a/frontend/src/components/molecules/EventButtonList.test.ts +++ b/frontend/src/components/molecules/EventButtonList.test.ts @@ -14,6 +14,7 @@ describe('StickyActions', () => { show: true, update: true, validate: false, + unarchive: false, }; expect(listAuthorizedEvents(acl)).toMatchSnapshot(); diff --git a/frontend/src/components/organisms/form-sections/HeadSection/index.tsx b/frontend/src/components/organisms/form-sections/HeadSection/index.tsx index a2dce6764..a111cd75b 100644 --- a/frontend/src/components/organisms/form-sections/HeadSection/index.tsx +++ b/frontend/src/components/organisms/form-sections/HeadSection/index.tsx @@ -12,6 +12,12 @@ import NotificationSubSection from './NotificationSubSection'; import { Event } from '../../../../config'; import Button from '../../../atoms/hyperTexts/Button'; import { useAuth } from '../../AuthContext'; +import { + eventConfigurations, + EnrollmentEvent, +} from '../../../../config/event-configuration'; +import { processEvent } from '../../../../lib/process-event'; +import { createOrUpdateEnrollment } from '../../../../services/enrollments'; export const HeadSection = () => { const { @@ -23,6 +29,22 @@ export const HeadSection = () => { const isUserAnAdministrator = getIsUserAnAdministrator(); + const handleUnarchiveClick = async () => { + const event = EnrollmentEvent.unarchive; + const eventConfiguration = eventConfigurations[event]; + + try { + const result = await processEvent( + event, + eventConfiguration, + enrollment, + updateEnrollment + ); + } catch (error) { + console.error('Errur lors du désarchivage:', error); + } + }; + return (
@@ -31,7 +53,12 @@ export const HeadSection = () => {

{label}

{isUserAnAdministrator && ( - )} diff --git a/frontend/src/config/event-configuration.tsx b/frontend/src/config/event-configuration.tsx index cd0414bd3..e28ab73ab 100644 --- a/frontend/src/config/event-configuration.tsx +++ b/frontend/src/config/event-configuration.tsx @@ -15,6 +15,7 @@ export enum EnrollmentEvent { instruct = 'instruct', update_contacts = 'update_contacts', copy = 'copy', + unarchive = 'unarchive', } export enum PromptType { @@ -23,6 +24,7 @@ export enum PromptType { confirm_deletion = 'confirm_deletion', confirm_archive = 'confirm_archive', submit_instead = 'submit_instead', + confirm_unarchive = 'confirm_unarchive', } export enum RequestType { @@ -137,4 +139,15 @@ export const eventConfigurations: { request: RequestType.change_state, redirectToHome: true, }, + [EnrollmentEvent.unarchive]: { + displayProps: { + label: 'Désarchiver', + icon: 'arrow-go-back-full', + secondary: true, + }, + prompt: PromptType.confirm_unarchive, + request: RequestType.change_state, + redirectToHome: true, + successMessage: 'L’habilitation a été désarchivée.', + }, }; diff --git a/frontend/src/lib/process-event.ts b/frontend/src/lib/process-event.ts index 8dc91e50e..d7c68dde0 100644 --- a/frontend/src/lib/process-event.ts +++ b/frontend/src/lib/process-event.ts @@ -1,4 +1,4 @@ -import { NetworkError, getErrorMessages } from '.'; +import { getErrorMessages, NetworkError } from '.'; import { EnrollmentEvent, EventConfiguration, From 79d55f07eac9534ceb646148737da8638d344d1d Mon Sep 17 00:00:00 2001 From: Isabelle Lafont Date: Thu, 30 Nov 2023 13:16:42 +0100 Subject: [PATCH 3/4] create unarchive event - add factory trait / adapt test --- backend/app/models/event.rb | 3 ++- backend/spec/factories/events.rb | 4 ++++ backend/spec/models/event_spec.rb | 4 ++++ .../organisms/form-sections/HeadSection/index.tsx | 8 +++++--- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/app/models/event.rb b/backend/app/models/event.rb index 3f9b1c7a1..59cf31a70 100644 --- a/backend/app/models/event.rb +++ b/backend/app/models/event.rb @@ -14,6 +14,7 @@ class Event < ApplicationRecord revoke reminder reminder_before_archive + unarchive opinion_created opinion_comment_created @@ -24,7 +25,7 @@ class Event < ApplicationRecord belongs_to :entity, polymorphic: true, optional: true belongs_to :user, optional: true - validates :user, presence: true, if: proc { |event| %w[reminder reminder_before_archive archive].exclude?(event.name) } + validates :user, presence: true, if: proc { |event| %w[reminder reminder_before_archive archive unarchive].exclude?(event.name) } validates :name, presence: true, inclusion: {in: VALID_NAMES} diff --git a/backend/spec/factories/events.rb b/backend/spec/factories/events.rb index 9e35bff2d..3b786313d 100644 --- a/backend/spec/factories/events.rb +++ b/backend/spec/factories/events.rb @@ -64,6 +64,10 @@ name { "archive" } end + trait :unarchive do + name { "unarchive" } + end + trait :opinion_created do name { "opinion_created" } diff --git a/backend/spec/models/event_spec.rb b/backend/spec/models/event_spec.rb index 2bed38d8b..d57aff85d 100644 --- a/backend/spec/models/event_spec.rb +++ b/backend/spec/models/event_spec.rb @@ -15,6 +15,7 @@ archive opinion_created opinion_comment_created + unarchive ].each do |trait| expect(build(:event, trait)).to be_valid end @@ -181,6 +182,7 @@ reminder reminder_before_archive archive + unarchive ].each do |name| context "when name is '#{name}'" do let(:name) { name } @@ -239,6 +241,8 @@ %w[ reminder reminder_before_archive + archive + unarchive ].each do |name| context "when name '#{name}'" do let(:name) { name } diff --git a/frontend/src/components/organisms/form-sections/HeadSection/index.tsx b/frontend/src/components/organisms/form-sections/HeadSection/index.tsx index a111cd75b..9f04d6255 100644 --- a/frontend/src/components/organisms/form-sections/HeadSection/index.tsx +++ b/frontend/src/components/organisms/form-sections/HeadSection/index.tsx @@ -17,7 +17,7 @@ import { EnrollmentEvent, } from '../../../../config/event-configuration'; import { processEvent } from '../../../../lib/process-event'; -import { createOrUpdateEnrollment } from '../../../../services/enrollments'; +import Enrollment from '../../../templates/Enrollment'; export const HeadSection = () => { const { @@ -32,16 +32,18 @@ export const HeadSection = () => { const handleUnarchiveClick = async () => { const event = EnrollmentEvent.unarchive; const eventConfiguration = eventConfigurations[event]; + const enrollment = Enrollment; + const updateEnrollment = Function; try { - const result = await processEvent( + await processEvent( event, eventConfiguration, enrollment, updateEnrollment ); } catch (error) { - console.error('Errur lors du désarchivage:', error); + console.error('Erreur lors du désarchivage:', error); } }; From bb392297abaff276b62c8baa63a45623b77a6145 Mon Sep 17 00:00:00 2001 From: Isabelle Lafont Date: Mon, 4 Dec 2023 15:12:38 +0100 Subject: [PATCH 4/4] frontend open confirmationModal and display btn archived for only archived enrollments backend implement logic unarchive --- .../app/controllers/enrollments_controller.rb | 11 +++ backend/app/models/enrollment.rb | 28 +++++++ backend/app/models/event.rb | 3 +- backend/app/policies/enrollment_policy.rb | 4 + backend/config/routes.rb | 1 + .../controllers/enrollments/unarchive_spec.rb | 51 +++++++++++++ .../components/atoms/icons/fr-fi-icons.tsx | 15 ++++ .../molecules/EventButtonList.test.ts | 1 - .../HeadSection/ActivityFeed.tsx | 5 ++ .../form-sections/HeadSection/index.tsx | 75 +++++++++++++------ frontend/src/config/event-configuration.tsx | 12 --- frontend/src/services/enrollments.tsx | 8 ++ 12 files changed, 176 insertions(+), 38 deletions(-) create mode 100644 backend/spec/controllers/enrollments/unarchive_spec.rb diff --git a/backend/app/controllers/enrollments_controller.rb b/backend/app/controllers/enrollments_controller.rb index cf6fb4fd9..792153061 100644 --- a/backend/app/controllers/enrollments_controller.rb +++ b/backend/app/controllers/enrollments_controller.rb @@ -225,6 +225,17 @@ def mark_event_as_processed render json: @enrollment end + # GET enrollment/1/unarchive + def unarchive + @enrollment = authorize Enrollment.find(params[:id]) + if @enrollment.status == "archived" + @enrollment.unarchive! + render json: @enrollment + else + render status: :unprocessable_entity, json: @enrollment.errors + end + end + private def pundit_params_for(_record) diff --git a/backend/app/models/enrollment.rb b/backend/app/models/enrollment.rb index 08125b7a5..2006aa62e 100644 --- a/backend/app/models/enrollment.rb +++ b/backend/app/models/enrollment.rb @@ -271,6 +271,34 @@ def archive! events.create!(name: "archive") end + def unarchive! + included_events = %w[create request_changes submit validate refuse revoke] + last_event = events.where(name: included_events).last + + last_event_name = last_event.name + + status_to_update = + case last_event_name + when "created" + "draft" + when "submit" + "submitted" + when "request_changes" + "changes_requested" + when "validate" + "validated" + when "refuse" + "refused" + when "revoke" + "revoked" + else + raise "Unexpected last event: #{last_event_name}" + end + + update(status: status_to_update) + events.create!(name: "unarchive", enrollment_id: id) + end + def team_members_json team_members .to_a.sort_by { |tm| tm.id } diff --git a/backend/app/models/event.rb b/backend/app/models/event.rb index 59cf31a70..5334878c2 100644 --- a/backend/app/models/event.rb +++ b/backend/app/models/event.rb @@ -14,10 +14,11 @@ class Event < ApplicationRecord revoke reminder reminder_before_archive - unarchive opinion_created opinion_comment_created + + unarchive ].freeze EVENTS_WITH_COMMENT_AS_EMAIL_BODY = %w[refuse request_changes validate revoke].freeze diff --git a/backend/app/policies/enrollment_policy.rb b/backend/app/policies/enrollment_policy.rb index b0f955a1f..9b6a067b6 100644 --- a/backend/app/policies/enrollment_policy.rb +++ b/backend/app/policies/enrollment_policy.rb @@ -74,6 +74,10 @@ def archive? record.can_archive_status? && (demandeur_rights || instructor_right || administrator_right) end + def unarchive? + record.status_archived? && user.is_administrator? + end + def refuse? record.can_refuse_status? && user.is_instructor?(record.target_api) end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index a11e15277..7360e1536 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -18,6 +18,7 @@ get :copies get :next_enrollments patch :mark_event_as_processed + patch :unarchive get :email_templates, to: "enrollments_email_templates#index" end diff --git a/backend/spec/controllers/enrollments/unarchive_spec.rb b/backend/spec/controllers/enrollments/unarchive_spec.rb new file mode 100644 index 000000000..fa8c7a969 --- /dev/null +++ b/backend/spec/controllers/enrollments/unarchive_spec.rb @@ -0,0 +1,51 @@ +RSpec.describe EnrollmentsController, "#unarchive", type: :controller do + describe "#unarchive" do + subject do + patch :unarchive, params: { + id: enrollment.id, + enrollment_status: enrollment.status + } + end + + let(:administrator) { create(:user, roles: ["administrator", "franceconnect:instructor"]) } + + before do + login(administrator) + end + + context "when administrator change status from archive to unarchive" do + let(:enrollment) do + create( + :enrollment, + enrollment_status, + :franceconnect + ) + end + let(:enrollment_status) { :submitted } + + let(:event) { + create( + :event, + name: "submit", + enrollment_id: enrollment.id + ) + } + + it "is expected to change enrollment status from archive to unarchive" do + enrollment.update(status: "archived") + create( + :event, + name: "archive", + enrollment_id: enrollment.id + ) + + expect { + # byebug + subject + }.to change { enrollment.reload.status }.to("submitted") + + expect(enrollment.events.last.name).to eq("unarchive") + end + end + end +end diff --git a/frontend/src/components/atoms/icons/fr-fi-icons.tsx b/frontend/src/components/atoms/icons/fr-fi-icons.tsx index 00ff13445..9da1f5497 100644 --- a/frontend/src/components/atoms/icons/fr-fi-icons.tsx +++ b/frontend/src/components/atoms/icons/fr-fi-icons.tsx @@ -370,3 +370,18 @@ export const RecycleIcon: React.FC = ({ title={title} /> ); + +export const UnarchiveIcon: React.FC = ({ + color, + large, + small, + title, +}) => ( + +); diff --git a/frontend/src/components/molecules/EventButtonList.test.ts b/frontend/src/components/molecules/EventButtonList.test.ts index 25979048f..83d51416a 100644 --- a/frontend/src/components/molecules/EventButtonList.test.ts +++ b/frontend/src/components/molecules/EventButtonList.test.ts @@ -14,7 +14,6 @@ describe('StickyActions', () => { show: true, update: true, validate: false, - unarchive: false, }; expect(listAuthorizedEvents(acl)).toMatchSnapshot(); diff --git a/frontend/src/components/organisms/form-sections/HeadSection/ActivityFeed.tsx b/frontend/src/components/organisms/form-sections/HeadSection/ActivityFeed.tsx index 73f19d169..56a4e4c4c 100644 --- a/frontend/src/components/organisms/form-sections/HeadSection/ActivityFeed.tsx +++ b/frontend/src/components/organisms/form-sections/HeadSection/ActivityFeed.tsx @@ -13,6 +13,7 @@ import { MailOpenIcon, InfoFillIcon, WarningIcon, + UnarchiveIcon, } from '../../../atoms/icons/fr-fi-icons'; import FileCopyIcon from '../../../atoms/icons/fileCopy'; import { Linkify } from '../../../molecules/Linkify'; @@ -93,6 +94,10 @@ const eventToDisplayableContent = { icon: , label: 'a répondu à un avis', }, + [EnrollmentEvent.unarchive]: { + icon: , + label: 'a désarchivé l’habilitation', + }, }; export const EventItem: React.FC = ({ diff --git a/frontend/src/components/organisms/form-sections/HeadSection/index.tsx b/frontend/src/components/organisms/form-sections/HeadSection/index.tsx index 9f04d6255..3a8a076ea 100644 --- a/frontend/src/components/organisms/form-sections/HeadSection/index.tsx +++ b/frontend/src/components/organisms/form-sections/HeadSection/index.tsx @@ -1,5 +1,5 @@ import { isEmpty } from 'lodash'; -import { useContext } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import Badge, { BadgeType } from '../../../atoms/hyperTexts/Badge'; import Link from '../../../atoms/hyperTexts/Link'; import { StatusBadge } from '../../../molecules/StatusBadge'; @@ -12,12 +12,10 @@ import NotificationSubSection from './NotificationSubSection'; import { Event } from '../../../../config'; import Button from '../../../atoms/hyperTexts/Button'; import { useAuth } from '../../AuthContext'; -import { - eventConfigurations, - EnrollmentEvent, -} from '../../../../config/event-configuration'; -import { processEvent } from '../../../../lib/process-event'; -import Enrollment from '../../../templates/Enrollment'; +import { unarchiveEnrollment } from '../../../../services/enrollments'; +import ConfirmationModal from '../../ConfirmationModal'; +import Alert, { AlertType } from '../../../atoms/Alert'; +import { Linkify } from '../../../molecules/Linkify'; export const HeadSection = () => { const { @@ -28,25 +26,32 @@ export const HeadSection = () => { const { getIsUserAnAdministrator } = useAuth(); const isUserAnAdministrator = getIsUserAnAdministrator(); + const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false); + const isArchived = status === 'archived'; + const [showAlert, setShowAlert] = useState(false); + const alertRef = useRef(null); - const handleUnarchiveClick = async () => { - const event = EnrollmentEvent.unarchive; - const eventConfiguration = eventConfigurations[event]; - const enrollment = Enrollment; - const updateEnrollment = Function; + const openConfirmationModal = () => { + setIsConfirmationModalOpen(true); + }; - try { - await processEvent( - event, - eventConfiguration, - enrollment, - updateEnrollment - ); - } catch (error) { - console.error('Erreur lors du désarchivage:', error); - } + const closeConfirmationModal = () => { + setIsConfirmationModalOpen(false); }; + const handleUnarchive = () => { + unarchiveEnrollment({ id: id }); + closeConfirmationModal(); + setShowAlert(true); + }; + + useEffect(() => { + // Scroll to the alert when it is displayed + if (showAlert && alertRef.current) { + alertRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [showAlert]); + return (
@@ -54,16 +59,24 @@ export const HeadSection = () => {

{label}

- {isUserAnAdministrator && ( + {!showAlert && isUserAnAdministrator && isArchived && ( )} + + {showAlert && ( +
+ + + +
+ )}
@@ -82,6 +95,20 @@ export const HeadSection = () => {
+ + {/* Render the ConfirmationModal */} + {isConfirmationModalOpen && ( + +

+ Vous êtes sur le point de désarchiver cette habilitation, elle + reprendra son statut initial dans votre liste d'habilitations. +

+
+ )} ); }; diff --git a/frontend/src/config/event-configuration.tsx b/frontend/src/config/event-configuration.tsx index e28ab73ab..6b2c79101 100644 --- a/frontend/src/config/event-configuration.tsx +++ b/frontend/src/config/event-configuration.tsx @@ -24,7 +24,6 @@ export enum PromptType { confirm_deletion = 'confirm_deletion', confirm_archive = 'confirm_archive', submit_instead = 'submit_instead', - confirm_unarchive = 'confirm_unarchive', } export enum RequestType { @@ -139,15 +138,4 @@ export const eventConfigurations: { request: RequestType.change_state, redirectToHome: true, }, - [EnrollmentEvent.unarchive]: { - displayProps: { - label: 'Désarchiver', - icon: 'arrow-go-back-full', - secondary: true, - }, - prompt: PromptType.confirm_unarchive, - request: RequestType.change_state, - redirectToHome: true, - successMessage: 'L’habilitation a été désarchivée.', - }, }; diff --git a/frontend/src/services/enrollments.tsx b/frontend/src/services/enrollments.tsx index e8aa171a2..19a0216b5 100644 --- a/frontend/src/services/enrollments.tsx +++ b/frontend/src/services/enrollments.tsx @@ -291,3 +291,11 @@ export function markEventAsRead({ ) .then(({ data }) => data); } + +export function unarchiveEnrollment({ id }: { id: number }) { + return httpClient + .patch(`${BACK_HOST}/api/enrollments/${id}/unarchive`, { + headers: { 'Content-type': 'application/json' }, + }) + .then(({ data }) => data); +}