From 56efe2bf9451b4350865a1b3dda25bf863ff069d Mon Sep 17 00:00:00 2001
From: "Dusan Mijatovic (PC2020)"
Date: Tue, 3 Sep 2024 20:13:30 +0200
Subject: [PATCH] feat: add testimonials to project pages
---
data-generation/main.js | 21 ++
.../007-create-relations-for-projects.sql | 31 ++
.../projects/edit/editProjectPages.tsx | 12 +
.../EditProjectTestimonialsIndex.test.tsx | 268 ++++++++++++++++++
.../__mocks__/project_testimonials.json | 16 ++
.../project_testimonials.json.license | 4 +
.../testimonials/apiProjectTestimonial.ts | 152 ++++++++++
.../projects/edit/testimonials/config.ts | 27 ++
.../projects/edit/testimonials/index.tsx | 191 +++++++++++++
.../testimonials/useProjectTestimonial.tsx | 148 ++++++++++
.../software/edit/editSoftwareConfig.tsx | 1 -
.../EditSoftwareTestimonialsIndex.test.tsx | 5 +-
.../testimonials/EditTestimonialModal.tsx | 10 +-
.../testimonials/apiSoftwareTestimonial.ts} | 41 +--
.../software/edit/testimonials/index.tsx | 175 ++++--------
.../testimonials/useSoftwareTestimonials.tsx | 150 ++++++++++
.../edit/testimonials/useTestimonials.tsx | 46 ---
frontend/pages/projects/[slug]/index.tsx | 17 +-
frontend/pages/software/[slug]/index.tsx | 5 +-
frontend/types/Testimonial.ts | 4 +-
frontend/utils/fetchHelpers.ts | 70 +++--
21 files changed, 1165 insertions(+), 229 deletions(-)
create mode 100644 frontend/components/projects/edit/testimonials/EditProjectTestimonialsIndex.test.tsx
create mode 100644 frontend/components/projects/edit/testimonials/__mocks__/project_testimonials.json
create mode 100644 frontend/components/projects/edit/testimonials/__mocks__/project_testimonials.json.license
create mode 100644 frontend/components/projects/edit/testimonials/apiProjectTestimonial.ts
create mode 100644 frontend/components/projects/edit/testimonials/config.ts
create mode 100644 frontend/components/projects/edit/testimonials/index.tsx
create mode 100644 frontend/components/projects/edit/testimonials/useProjectTestimonial.tsx
rename frontend/{utils/editTestimonial.ts => components/software/edit/testimonials/apiSoftwareTestimonial.ts} (72%)
create mode 100644 frontend/components/software/edit/testimonials/useSoftwareTestimonials.tsx
delete mode 100644 frontend/components/software/edit/testimonials/useTestimonials.tsx
diff --git a/data-generation/main.js b/data-generation/main.js
index 590d81f24..818d77f0e 100644
--- a/data-generation/main.js
+++ b/data-generation/main.js
@@ -151,6 +151,26 @@ function generateTestimonials(ids) {
software: id,
message: faker.hacker.phrase(),
source: faker.person.fullName(),
+ position: index + 1,
+ });
+ }
+ }
+
+ return result;
+}
+
+function generateProjectTestimonials(ids) {
+ const result = [];
+
+ for (const id of ids) {
+ // each project will get 0, 1 or 2 testimonials
+ const numberOfTestimonials = faker.number.int({max: 3, min: 0});
+ for (let index = 0; index < numberOfTestimonials; index++) {
+ result.push({
+ project: id,
+ message: faker.hacker.phrase(),
+ source: faker.person.fullName(),
+ position: index + 1,
});
}
}
@@ -1019,6 +1039,7 @@ const projectPromise = postToBackend('/project', generateProjects())
idsProjects = pjArray.map(sw => sw['id']);
postToBackend('/team_member', await generateTeamMembers(idsProjects, peopleWithOrcid));
postToBackend('/url_for_project', generateUrlsForProjects(idsProjects));
+ postToBackend('/testimonial_for_project', generateProjectTestimonials(idsProjects));
postToBackend('/keyword_for_project', generateKeywordsForEntity(idsProjects, idsKeywords, 'project'));
postToBackend('/output_for_project', generateMentionsForEntity(idsProjects, idsMentions, 'project'));
postToBackend('/impact_for_project', generateMentionsForEntity(idsProjects, idsMentions, 'project'));
diff --git a/database/007-create-relations-for-projects.sql b/database/007-create-relations-for-projects.sql
index 9ac74fa95..9dce26daf 100644
--- a/database/007-create-relations-for-projects.sql
+++ b/database/007-create-relations-for-projects.sql
@@ -2,6 +2,7 @@
-- SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center
-- SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
-- SPDX-FileCopyrightText: 2022 dv4all
+-- SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
--
-- SPDX-License-Identifier: Apache-2.0
@@ -45,3 +46,33 @@ END
$$;
CREATE TRIGGER sanitise_update_team_member BEFORE UPDATE ON team_member FOR EACH ROW EXECUTE PROCEDURE sanitise_update_team_member();
+
+
+CREATE TABLE testimonial_for_project (
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+ project UUID REFERENCES project (id) NOT NULL,
+ message VARCHAR(500) NOT NULL,
+ source VARCHAR(200) NOT NULL,
+ position INTEGER
+);
+
+CREATE FUNCTION sanitise_insert_testimonial_for_project() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+ NEW.id = gen_random_uuid();
+ return NEW;
+END
+$$;
+
+CREATE TRIGGER sanitise_insert_testimonial_for_project BEFORE INSERT ON testimonial_for_project FOR EACH ROW EXECUTE PROCEDURE sanitise_insert_testimonial_for_project();
+
+
+CREATE FUNCTION sanitise_update_testimonial_for_project() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+ NEW.id = OLD.id;
+ return NEW;
+END
+$$;
+
+CREATE TRIGGER sanitise_update_testimonial_for_project BEFORE UPDATE ON testimonial_for_project FOR EACH ROW EXECUTE PROCEDURE sanitise_update_testimonial_for_project();
diff --git a/frontend/components/projects/edit/editProjectPages.tsx b/frontend/components/projects/edit/editProjectPages.tsx
index b47051372..7652c5669 100644
--- a/frontend/components/projects/edit/editProjectPages.tsx
+++ b/frontend/components/projects/edit/editProjectPages.tsx
@@ -16,6 +16,7 @@ import TerminalIcon from '@mui/icons-material/Terminal'
import ContentLoader from '~/components/layout/ContentLoader'
import JoinInnerIcon from '@mui/icons-material/JoinInner'
import AddCommentIcon from '@mui/icons-material/AddComment'
+import ThreePIcon from '@mui/icons-material/ThreeP'
// use dynamic imports
const ProjectInformation = dynamic(() => import('./information'),{
@@ -36,6 +37,10 @@ const RelatedProjects = dynamic(() => import('./related-projects'),{
const RelatedSoftware = dynamic(() => import('./related-software'),{
loading: ()=>
})
+const ProjectTestimonials = dynamic(() => import('./testimonials'),{
+ loading: ()=>
+})
+
const ProjectMaintainers = dynamic(() => import('./maintainers'),{
loading: ()=>
})
@@ -77,6 +82,13 @@ export const editProjectPage: EditProjectPageProps[] = [
render: () => ,
status: ''
},
+ {
+ id: 'testimonials',
+ label: 'Testimonials',
+ icon: ,
+ render: () => ,
+ status: ''
+ },
{
id: 'related-projects',
label: 'Related projects',
diff --git a/frontend/components/projects/edit/testimonials/EditProjectTestimonialsIndex.test.tsx b/frontend/components/projects/edit/testimonials/EditProjectTestimonialsIndex.test.tsx
new file mode 100644
index 000000000..85b8711ac
--- /dev/null
+++ b/frontend/components/projects/edit/testimonials/EditProjectTestimonialsIndex.test.tsx
@@ -0,0 +1,268 @@
+// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all)
+// SPDX-FileCopyrightText: 2023 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {fireEvent, render, screen, waitFor, waitForElementToBeRemoved, within} from '@testing-library/react'
+
+import {WithAppContext, mockSession} from '~/utils/jest/WithAppContext'
+import {WithProjectContext} from '~/utils/jest/WithProjectContext'
+import {initialState as projectState} from '~/components/projects/edit/editProjectContext'
+
+import SoftwareTestimonials from './index'
+import config from './config'
+
+// MOCKS
+import mockTestimonials from './__mocks__/project_testimonials.json'
+
+// Mock editTestimonial api calls
+const mockGetTestimonialsForProject = jest.fn(props => Promise.resolve(mockTestimonials))
+const mockPostTestimonial = jest.fn(({testimonial}) => {
+ return Promise.resolve({
+ status: 201,
+ message: testimonial
+ })
+})
+const mockDeleteProjectTestimonial = jest.fn(props => Promise.resolve([] as any))
+const mockPatchTestimonialPositions = jest.fn(props => Promise.resolve([] as any))
+jest.mock('./apiProjectTestimonial', () => ({
+ getTestimonialsForProject: jest.fn(props => mockGetTestimonialsForProject(props)),
+ addProjectTestimonial: jest.fn(props => mockPostTestimonial(props)),
+ deleteProjectTestimonial: jest.fn(props => mockDeleteProjectTestimonial(props)),
+ patchTestimonialPositions: jest.fn(props => mockPatchTestimonialPositions(props)),
+}))
+
+describe('frontend/components/projects/edit/testimonials/index.tsx', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders no testimonials message', async() => {
+ // required prop
+ projectState.project.id = 'test-project-id'
+ // return no items
+ mockGetTestimonialsForProject.mockResolvedValueOnce([])
+
+ render(
+
+
+
+
+
+ )
+
+ // wait for loader to
+ await waitForElementToBeRemoved(screen.getByRole('progressbar'))
+ // validate no items message
+ const noItemsMsg = screen.getByText('No testimonials')
+ })
+
+ it('renders mocked testimonials', async() => {
+ // required prop
+ projectState.project.id = 'test-project-id'
+ // return no items
+ mockGetTestimonialsForProject.mockResolvedValueOnce(mockTestimonials)
+
+ render(
+
+
+
+
+
+ )
+
+ // wait for loader to
+ await waitForElementToBeRemoved(screen.getByRole('progressbar'))
+
+ // validate number of items
+ const testimonials = screen.getAllByTestId('testimonial-list-item')
+ expect(testimonials.length).toEqual(mockTestimonials.length)
+ // validate first item message
+ expect(testimonials[0]).toHaveTextContent(mockTestimonials[0].message)
+ })
+
+ it('can add testimonial', async() => {
+ // required prop
+ projectState.project.id = 'test-project-id'
+ // return no items
+ mockGetTestimonialsForProject.mockResolvedValueOnce([])
+
+ const newItem = {
+ message: 'This is test message',
+ source: 'This is test source'
+ }
+
+ render(
+
+
+
+
+
+ )
+
+ // wait for loader to
+ await waitForElementToBeRemoved(screen.getByRole('progressbar'))
+
+ // click add button
+ const addBtn = screen.getByRole('button', {
+ name: 'Add'
+ })
+ fireEvent.click(addBtn)
+
+ // get modal
+ const modal = screen.getByRole('dialog')
+
+ // write message
+ const message = screen.getByRole('textbox', {
+ name: config.message.label,
+ })
+ fireEvent.change(message, {target: {value: newItem.message}})
+
+ // write source
+ const source = screen.getByRole('textbox', {
+ name: config.source.label,
+ })
+ fireEvent.change(source, {target: {value: newItem.source}})
+
+ // click on save
+ const saveBtn = screen.getByRole('button', {
+ name: 'Save'
+ })
+ await waitFor(() => {
+ expect(saveBtn).toBeEnabled()
+ fireEvent.click(saveBtn)
+ })
+
+ // validate api call
+ await waitFor(() => {
+ expect(mockPostTestimonial).toBeCalledTimes(1)
+ expect(mockPostTestimonial).toBeCalledWith({
+ 'testimonial': {
+ 'id': null,
+ 'message': newItem.message,
+ 'position': 1,
+ 'project': projectState.project.id,
+ 'source': newItem.source,
+ },
+ 'token': 'TEST_TOKEN'
+ })
+ })
+ })
+
+ it('can edit testimonial', async() => {
+ // required prop
+ projectState.project.id = 'test-project-id'
+ // return no items
+ mockGetTestimonialsForProject.mockResolvedValueOnce(mockTestimonials)
+
+ render(
+
+
+
+
+
+ )
+
+ // wait for loader to
+ await waitForElementToBeRemoved(screen.getByRole('progressbar'))
+
+ // get items
+ const testimonials = screen.getAllByTestId('testimonial-list-item')
+ // edit btn
+ const editBtn = within(testimonials[0]).getByRole('button', {
+ name: 'edit'
+ })
+ fireEvent.click(editBtn)
+
+ // confirm modal has values
+ const modal = screen.getByRole('dialog')
+
+ // validate message value
+ const message = screen.getByRole('textbox', {
+ name: config.message.label,
+ })
+ expect(message).toHaveValue(mockTestimonials[0].message)
+
+ // validate source value
+ const source = screen.getByRole('textbox', {
+ name: config.source.label,
+ })
+ expect(source).toHaveValue(mockTestimonials[0].source)
+
+ // Cancel update action
+ const cancelBtn = screen.getByRole('button', {
+ name: 'Cancel'
+ })
+ fireEvent.click(cancelBtn)
+ // validate modal is closed
+ expect(modal).not.toBeVisible()
+ })
+
+ it('can delete testimonial', async () => {
+ // required prop
+ projectState.project.id = 'test-project-id'
+ // return no items
+ mockGetTestimonialsForProject.mockResolvedValueOnce(mockTestimonials)
+ // mock delete response
+ mockDeleteProjectTestimonial.mockResolvedValueOnce({
+ status: 200,
+ message: 'OK'
+ })
+ // mock patch response
+ mockPatchTestimonialPositions.mockResolvedValueOnce({
+ status: 200,
+ message: 'OK'
+ })
+
+ render(
+
+
+
+
+
+ )
+
+ // wait for loader to
+ await waitForElementToBeRemoved(screen.getByRole('progressbar'))
+
+ // get items
+ const testimonials = screen.getAllByTestId('testimonial-list-item')
+ // edit btn
+ const deleteBtn = within(testimonials[0]).getByRole('button', {
+ name: 'delete'
+ })
+ fireEvent.click(deleteBtn)
+
+ // confirm modal
+ const confirmModal = screen.getByRole('dialog', {
+ name: 'Remove testimonial'
+ })
+ // click on Remove button
+ const removeBtn = within(confirmModal).getByRole('button', {
+ name: 'Remove'
+ })
+ fireEvent.click(removeBtn)
+
+ // validate api calls
+ await waitFor(() => {
+ // validate delete testimonial api
+ expect(mockDeleteProjectTestimonial).toBeCalledTimes(1)
+ expect(mockDeleteProjectTestimonial).toBeCalledWith({
+ 'id': mockTestimonials[0].id,
+ 'token': mockSession.token,
+ })
+ // validate patch testimonial positions called
+ expect(mockPatchTestimonialPositions).toBeCalledTimes(1)
+ })
+
+ await waitFor(async() => {
+ // validate item removed from list
+ const remained = await screen.findAllByTestId('testimonial-list-item')
+ expect(remained.length).toEqual(testimonials.length - 1)
+ })
+ })
+})
+
diff --git a/frontend/components/projects/edit/testimonials/__mocks__/project_testimonials.json b/frontend/components/projects/edit/testimonials/__mocks__/project_testimonials.json
new file mode 100644
index 000000000..a8529b7a4
--- /dev/null
+++ b/frontend/components/projects/edit/testimonials/__mocks__/project_testimonials.json
@@ -0,0 +1,16 @@
+[
+ {
+ "id": "eec59b21-1fb6-4e63-830b-fcf837c812c1",
+ "project": "2930a586-6781-4091-af8e-7411f9be76d4",
+ "message": "This is my testimonal for msedge 1",
+ "source": "Me, Here, Today",
+ "position": 1
+ },
+ {
+ "id": "e53e1572-0aad-4b5d-817b-219b20138c57",
+ "project": "2930a586-6781-4091-af8e-7411f9be76d4",
+ "message": "This is my testimonal for msedge 2",
+ "source": "You, There, Yesterday",
+ "position": 2
+ }
+]
\ No newline at end of file
diff --git a/frontend/components/projects/edit/testimonials/__mocks__/project_testimonials.json.license b/frontend/components/projects/edit/testimonials/__mocks__/project_testimonials.json.license
new file mode 100644
index 000000000..f5831b46e
--- /dev/null
+++ b/frontend/components/projects/edit/testimonials/__mocks__/project_testimonials.json.license
@@ -0,0 +1,4 @@
+SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/frontend/components/projects/edit/testimonials/apiProjectTestimonial.ts b/frontend/components/projects/edit/testimonials/apiProjectTestimonial.ts
new file mode 100644
index 000000000..77cfb7ecb
--- /dev/null
+++ b/frontend/components/projects/edit/testimonials/apiProjectTestimonial.ts
@@ -0,0 +1,152 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {NewTestimonial, Testimonial} from '~/types/Testimonial'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+import logger from '~/utils/logger'
+
+export type NewProjectTestimonial = NewTestimonial & {
+ project: string
+}
+
+export type ProjectTestimonial = NewProjectTestimonial & {
+ id: string
+ position: number
+}
+
+export async function getTestimonialsForProject({project,token}:{project:string,token:string}){
+ try{
+ const url = `${getBaseUrl()}/testimonial_for_project?project=eq.${project}&order=position`
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: {
+ ...createJsonHeaders(token),
+ },
+ })
+ if (resp.ok){
+ const testimonials:ProjectTestimonial[] = await resp.json()
+ return testimonials
+ }
+ logger(`getTestimonialsForProject: ${resp.status} ${resp.statusText}`, 'warn')
+ return []
+ }catch(e:any){
+ logger(`getTestimonialsForProject: ${e?.message}`, 'error')
+ return []
+ }
+}
+
+export async function addProjectTestimonial({testimonial,token}:{testimonial:NewProjectTestimonial,token:string}){
+ try{
+ const url = `${getBaseUrl()}/testimonial_for_project`
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: {
+ ...createJsonHeaders(token),
+ 'Prefer': 'return=representation',
+ },
+ body: JSON.stringify(testimonial)
+ })
+ if (resp.status === 201) {
+ const json = await resp.json()
+ if (json.length > 0) {
+ // we return stored record
+ return {
+ status: 201,
+ message: json[0]
+ }
+ } else {
+ logger('addProjectTestimonial: resp.json() returned no records', 'error')
+ return {
+ status: 400,
+ message: 'Bad request'
+ }
+ }
+ } else {
+ return extractReturnMessage(resp)
+ }
+ }catch(e:any){
+ logger(`addProjectTestimonial: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e.message
+ }
+ }
+}
+
+export async function updateProjectTestimonial({data,token}:{data:ProjectTestimonial,token:string}){
+ try{
+ const url = `${getBaseUrl()}/testimonial_for_project?id=eq.${data.id}`
+ const resp = await fetch(url, {
+ method: 'PATCH',
+ headers: {
+ ...createJsonHeaders(token),
+ },
+ body: JSON.stringify(data)
+ })
+ return extractReturnMessage(resp,data.id)
+ }catch(e:any){
+ logger(`patchProjectTestimonial: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e.message
+ }
+ }
+}
+
+export async function deleteProjectTestimonial({id,token}:{id:string,token:string}){
+ try {
+ const url = `${getBaseUrl()}/testimonial_for_project?id=eq.${id}`
+ const resp = await fetch(url, {
+ method: 'DELETE',
+ headers: {
+ ...createJsonHeaders(token),
+ }
+ })
+ return extractReturnMessage(resp, id)
+ } catch (e: any) {
+ logger(`deleteProjectTestimonial: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
+
+/**
+ * Patch testimonial position.
+ * For this we do not need project id, we patch by testimonial id.
+ * @param param0
+ * @returns
+ */
+export async function patchTestimonialPositions({testimonials, token}: { testimonials: Testimonial[], token: string }) {
+ try {
+ // if the array is empty return
+ if (testimonials.length === 0) return {status:200,message:'OK'}
+ // create all requests
+ const requests = testimonials.map(testimonial => {
+ const url = `${getBaseUrl()}/testimonial_for_project?id=eq.${testimonial.id}`
+ return fetch(url, {
+ method: 'PATCH',
+ headers: {
+ ...createJsonHeaders(token),
+ },
+ // just update position!
+ body: JSON.stringify({
+ position: testimonial.position
+ })
+ })
+ })
+ // execute them in parallel
+ const responses = await Promise.all(requests)
+ // check for errors
+ return extractReturnMessage(responses[0])
+ } catch (e: any) {
+ logger(`patchTestimonialPositions: ${e?.message}`, 'error')
+ return {
+ status: 500,
+ message: e?.message
+ }
+ }
+}
diff --git a/frontend/components/projects/edit/testimonials/config.ts b/frontend/components/projects/edit/testimonials/config.ts
new file mode 100644
index 000000000..1436b5994
--- /dev/null
+++ b/frontend/components/projects/edit/testimonials/config.ts
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+const config = {
+ message: {
+ label: 'Message',
+ help: 'What credits the project received?',
+ validation: {
+ required: 'The message is required',
+ minLength: {value: 2, message: 'Minimum length is 2'},
+ maxLength: {value: 500, message: 'Maximum length is 500'},
+ }
+ },
+ source: {
+ label: 'Source',
+ help: 'Who provided the credits?',
+ validation: {
+ required: 'The source of the testimonial is required',
+ minLength: {value: 2, message: 'Minimum length is 2'},
+ maxLength: {value: 200, message: 'Maximum length is 200'},
+ }
+ }
+}
+
+export default config
diff --git a/frontend/components/projects/edit/testimonials/index.tsx b/frontend/components/projects/edit/testimonials/index.tsx
new file mode 100644
index 000000000..40eee2c15
--- /dev/null
+++ b/frontend/components/projects/edit/testimonials/index.tsx
@@ -0,0 +1,191 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useState} from 'react'
+import AddIcon from '@mui/icons-material/Add'
+import Button from '@mui/material/Button'
+
+import {NewTestimonial, Testimonial} from '~/types/Testimonial'
+import EditSection from '~/components/layout/EditSection'
+import EditSectionTitle from '~/components/layout/EditSectionTitle'
+import ContentLoader from '~/components/layout/ContentLoader'
+import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal'
+import SortableTestimonialList from '~/components/software/edit/testimonials/SortableTestimonialList'
+import EditTestimonialModal from '~/components/software/edit/testimonials/EditTestimonialModal'
+import {ModalProps} from '~/components/software/edit/editSoftwareTypes'
+import {NewProjectTestimonial,ProjectTestimonial} from './apiProjectTestimonial'
+import useProjectTestimonial from './useProjectTestimonial'
+import config from './config'
+
+type EditTestimonialModal = ModalProps & {
+ testimonial?: NewProjectTestimonial | ProjectTestimonial
+}
+
+export default function ProjectTestimonials() {
+ const {
+ loading, testimonials, project,
+ addTestimonial, updateTestimonial,
+ deleteTestimonial,updateTestimonialPosition
+ } = useProjectTestimonial()
+
+ const [modal,setModal] = useState<{
+ edit:{
+ open:boolean,
+ pos?: number,
+ },
+ delete:{
+ open:boolean
+ },
+ testimonial?: NewTestimonial | Testimonial
+ }>({
+ edit:{
+ open:false,
+ pos: 0
+ },
+ delete:{
+ open:false,
+ }
+ })
+
+ // console.group("ProjectTestimonials")
+ // console.log('testimonials...', testimonials)
+ // console.log("loading...", loading)
+ // console.groupEnd()
+
+ function onAdd(){
+ setModal({
+ edit:{
+ open: true,
+ },
+ delete:{
+ open: false
+ },
+ testimonial:{
+ id: null,
+ message: null,
+ source: null,
+ position: testimonials.length + 1
+ }
+ })
+ }
+
+ function onEdit(pos:number){
+ setModal({
+ edit:{
+ open: true,
+ pos
+ },
+ delete:{
+ open:false
+ },
+ testimonial: testimonials[pos]
+ })
+ }
+
+ function onDelete(pos:number){
+ setModal({
+ edit:{
+ open: false,
+ pos
+ },
+ delete:{
+ open:true
+ },
+ testimonial: testimonials[pos]
+ })
+ }
+
+ function onSubmit({data,pos}:{data:Testimonial|NewTestimonial,pos?:number}){
+ closeModals()
+
+ if (data.id === null){
+ // create new item
+ addTestimonial(data)
+ }else if (typeof pos === 'number'){
+ // update item
+ updateTestimonial({data: data as Testimonial, pos})
+ }
+ }
+
+ function getTestimonialSubtitle() {
+ if (testimonials?.length === 1) {
+ return `${project?.title} has 1 testimonial`
+ }
+ return `${project?.title} has ${testimonials?.length} testimonials`
+ }
+
+ function closeModals(){
+ setModal({
+ edit:{
+ open: false,
+ pos: 0
+ },
+ delete:{
+ open: false
+ }
+ })
+ }
+
+ // if loading show loader
+ if (loading) return (
+
+ )
+
+ return (
+
+
+
+
+ }
+ onClick={onAdd}
+ >
+ Add
+
+
+
+
+
+ {modal.edit.open ?
+
+ : null
+ }
+ {modal.delete.open ?
+ Are you sure you want to remove testimonial from source {modal?.testimonial?.source ?? ''}?
+ }
+ onCancel={closeModals}
+ onDelete={()=>{
+ if (modal?.testimonial?.id){
+ deleteTestimonial(modal?.testimonial?.id)
+ }
+ closeModals()
+ }}
+ />
+ : null
+ }
+
+ )
+}
diff --git a/frontend/components/projects/edit/testimonials/useProjectTestimonial.tsx b/frontend/components/projects/edit/testimonials/useProjectTestimonial.tsx
new file mode 100644
index 000000000..3d3e7e8bb
--- /dev/null
+++ b/frontend/components/projects/edit/testimonials/useProjectTestimonial.tsx
@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useCallback, useEffect, useState} from 'react'
+
+import {useSession} from '~/auth'
+import {NewTestimonial, Testimonial} from '~/types/Testimonial'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import useProjectContext from '../useProjectContext'
+import {addProjectTestimonial, deleteProjectTestimonial,
+ getTestimonialsForProject, patchTestimonialPositions,
+ updateProjectTestimonial
+} from './apiProjectTestimonial'
+import {sortOnNumProp} from '~/utils/sortFn'
+
+export default function useProjectTestimonial(){
+ const {token} = useSession()
+ const {project} = useProjectContext()
+ const [loading, setLoading] = useState(true)
+ const [testimonials, setTestimonials] = useState([])
+ const {showErrorMessage} = useSnackbar()
+
+ const getTestimonials = useCallback(async()=>{
+ if (project.id && token){
+ const testimonials = await getTestimonialsForProject({
+ project: project.id,
+ token
+ })
+ setTestimonials(testimonials)
+ setLoading(false)
+ }
+ },[project.id,token])
+
+ const addTestimonial = useCallback(async(item:NewTestimonial)=>{
+ if (project.id && token){
+ const resp = await addProjectTestimonial({
+ testimonial:{
+ ...item,
+ project: project.id
+ },
+ token
+ })
+ if (resp.status!==201){
+ showErrorMessage(`Failed to add testimonial. ${resp.message}`)
+ }else{
+ // add new item
+ setTestimonials((data)=>{
+ return [
+ ...data,
+ resp.message
+ ]
+ })
+ }
+ }
+ // ignore showErrorMessage
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,project.id])
+
+ const updateTestimonial = useCallback(async({data, pos}: { data: Testimonial, pos: number })=>{
+ if (project.id && token && data.id){
+ const testimonial = {
+ ...data,
+ project: project.id
+ }
+ const resp = await updateProjectTestimonial({
+ data:testimonial,
+ token
+ })
+ if (resp.status==200){
+ setTestimonials((testimonials)=>{
+ // replace item in state
+ const list = [
+ ...testimonials.slice(0, pos),
+ testimonial,
+ ...testimonials.slice(pos+1)
+ ].sort((a,b)=>sortOnNumProp(a,b,'position'))
+ return list
+ })
+ }else{
+ showErrorMessage(`Failed to update testimonial. ${resp.message}`)
+ }
+ }
+ // ignore showErrorMessage as dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,project.id])
+
+ const deleteTestimonial = useCallback(async(id:string)=>{
+ if (token && id){
+ const resp = await deleteProjectTestimonial({
+ id,
+ token
+ })
+ if (resp.status===200){
+ // remove item from the list
+ const list = testimonials
+ .filter(item=>item.id!==id)
+ .map((item,pos) => {
+ item.position = pos + 1
+ return item
+ })
+ // patch testimonials position
+ await updateTestimonialPosition(list)
+ }else{
+ showErrorMessage(`Failed to delete testimonial. ${resp.message}`)
+ }
+ }
+ // ignore showErrorMessage as dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,testimonials])
+
+ const updateTestimonialPosition = useCallback(async(newList: Testimonial[])=>{
+ // update ui first
+ setTestimonials(newList)
+
+ // update db
+ const resp = await patchTestimonialPositions({
+ testimonials: newList,
+ token
+ })
+
+ if (resp.status!==200){
+ // revert back
+ setTestimonials(testimonials)
+ showErrorMessage(`Failed to update positions. ${resp.message}`)
+ }
+ // ignore showErrorMessage as dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,testimonials])
+
+
+ useEffect(()=>{
+ if (project.id && token){
+ getTestimonials()
+ }
+ },[project.id,token,getTestimonials])
+
+ return {
+ loading,
+ testimonials,
+ project,
+ addTestimonial,
+ updateTestimonial,
+ updateTestimonialPosition,
+ deleteTestimonial
+ }
+}
diff --git a/frontend/components/software/edit/editSoftwareConfig.tsx b/frontend/components/software/edit/editSoftwareConfig.tsx
index cda5138b5..e29af8349 100644
--- a/frontend/components/software/edit/editSoftwareConfig.tsx
+++ b/frontend/components/software/edit/editSoftwareConfig.tsx
@@ -235,7 +235,6 @@ export const testimonialInformation = {
}
}
-
export const mentionInformation = {
sectionTitle: 'Mentions',
mentionType: {
diff --git a/frontend/components/software/edit/testimonials/EditSoftwareTestimonialsIndex.test.tsx b/frontend/components/software/edit/testimonials/EditSoftwareTestimonialsIndex.test.tsx
index 3f6c2c3bb..506a1c274 100644
--- a/frontend/components/software/edit/testimonials/EditSoftwareTestimonialsIndex.test.tsx
+++ b/frontend/components/software/edit/testimonials/EditSoftwareTestimonialsIndex.test.tsx
@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all)
// SPDX-FileCopyrightText: 2023 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
@@ -26,14 +28,13 @@ const mockPostTestimonial = jest.fn(({testimonial}) => {
})
const mockDeleteTestimonialById = jest.fn(props => Promise.resolve([] as any))
const mockPatchTestimonialPositions = jest.fn(props => Promise.resolve([] as any))
-jest.mock('~/utils/editTestimonial', () => ({
+jest.mock('./apiSoftwareTestimonial', () => ({
getTestimonialsForSoftware: jest.fn(props => mockGetTestimonialsForSoftware(props)),
postTestimonial: jest.fn(props => mockPostTestimonial(props)),
deleteTestimonialById: jest.fn(props => mockDeleteTestimonialById(props)),
patchTestimonialPositions: jest.fn(props => mockPatchTestimonialPositions(props)),
}))
-
describe('frontend/components/software/edit/testimonials/index.tsx', () => {
beforeEach(() => {
jest.clearAllMocks()
diff --git a/frontend/components/software/edit/testimonials/EditTestimonialModal.tsx b/frontend/components/software/edit/testimonials/EditTestimonialModal.tsx
index 92ecee567..37bb9ac84 100644
--- a/frontend/components/software/edit/testimonials/EditTestimonialModal.tsx
+++ b/frontend/components/software/edit/testimonials/EditTestimonialModal.tsx
@@ -3,6 +3,8 @@
// SPDX-FileCopyrightText: 2022 Christian Meeßen (GFZ)
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (dv4all)
// SPDX-FileCopyrightText: 2022 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
@@ -16,11 +18,11 @@ import useMediaQuery from '@mui/material/useMediaQuery'
import {useForm} from 'react-hook-form'
import ControlledTextField from '../../../form/ControlledTextField'
-import {testimonialInformation as config} from '../editSoftwareConfig'
import {NewTestimonial, Testimonial} from '../../../../types/Testimonial'
import SubmitButtonWithListener from '~/components/form/SubmitButtonWithListener'
type EditTestimonialModalProps = {
+ config: any,
open: boolean,
onCancel: () => void,
onSubmit: ({data, pos}: { data: Testimonial|NewTestimonial, pos?: number }) => void,
@@ -31,7 +33,7 @@ type EditTestimonialModalProps = {
const formId='edit-testimonial-modal'
-export default function EditTestimonialModal({open, onCancel, onSubmit, testimonial, pos}: EditTestimonialModalProps) {
+export default function EditTestimonialModal({config,open,onCancel,onSubmit,testimonial,pos}: EditTestimonialModalProps) {
const smallScreen = useMediaQuery('(max-width:600px)')
const {handleSubmit, watch, formState, reset, control, register, setValue} = useForm({
mode: 'onChange',
@@ -83,9 +85,9 @@ export default function EditTestimonialModal({open, onCancel, onSubmit, testimon
-
+ /> */}
diff --git a/frontend/utils/editTestimonial.ts b/frontend/components/software/edit/testimonials/apiSoftwareTestimonial.ts
similarity index 72%
rename from frontend/utils/editTestimonial.ts
rename to frontend/components/software/edit/testimonials/apiSoftwareTestimonial.ts
index df7781d0d..3b5dcd765 100644
--- a/frontend/utils/editTestimonial.ts
+++ b/frontend/components/software/edit/testimonials/apiSoftwareTestimonial.ts
@@ -1,28 +1,35 @@
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0
-import {createJsonHeaders, extractReturnMessage} from './fetchHelpers'
-import {NewTestimonial, Testimonial} from '../types/Testimonial'
-import logger from './logger'
+import logger from '~/utils/logger'
+import {createJsonHeaders, extractReturnMessage, getBaseUrl} from '~/utils/fetchHelpers'
+import {NewTestimonial, Testimonial} from '~/types/Testimonial'
+export type NewSoftwareTestimonial = NewTestimonial & {
+ software: string
+}
+
+export type SoftwareTestimonial = NewSoftwareTestimonial & {
+ id: string
+ position: number
+}
-export async function getTestimonialsForSoftware({software, frontend, token}:
- {software: string, frontend?: boolean, token?: string}) {
+export async function getTestimonialsForSoftware({software, token}:
+ {software: string, token?: string}) {
try {
- let url = `${process.env.POSTGREST_URL}/testimonial?software=eq.${software}&order=position.asc`
- if (frontend === true) {
- url = `/api/v1/testimonial?software=eq.${software}&order=position.asc`
- }
+ let url = `${getBaseUrl()}/testimonial?software=eq.${software}&order=position.asc`
const resp = await fetch(url, {
method: 'GET',
headers: createJsonHeaders(token)
})
if (resp.status === 200) {
- const data: Testimonial[] = await resp.json()
+ const data: SoftwareTestimonial[] = await resp.json()
// update position to reflect array
return data.map((item, pos) => {
return {
@@ -41,9 +48,9 @@ export async function getTestimonialsForSoftware({software, frontend, token}:
}
}
-export async function postTestimonial({testimonial, token}: { testimonial: NewTestimonial, token: string }) {
+export async function postTestimonial({testimonial, token}: { testimonial: NewSoftwareTestimonial, token: string }) {
try {
- const url = '/api/v1/testimonial'
+ const url = `${getBaseUrl()}/testimonial`
const resp = await fetch(url, {
method: 'POST',
headers: {
@@ -80,9 +87,9 @@ export async function postTestimonial({testimonial, token}: { testimonial: NewTe
}
-export async function patchTestimonial({testimonial, token}: { testimonial: Testimonial, token: string }) {
+export async function patchTestimonial({testimonial, token}: { testimonial: SoftwareTestimonial, token: string }) {
try {
- const url = `/api/v1/testimonial?id=eq.${testimonial.id}`
+ const url = `${getBaseUrl()}/testimonial?id=eq.${testimonial.id}`
const resp = await fetch(url, {
method: 'PATCH',
headers: {
@@ -102,9 +109,11 @@ export async function patchTestimonial({testimonial, token}: { testimonial: Test
export async function patchTestimonialPositions({testimonials, token}: { testimonials: Testimonial[], token: string }) {
try {
+ // if the array is empty return
+ if (testimonials.length === 0) return {status:200,message:'OK'}
// create all requests
const requests = testimonials.map(testimonial => {
- const url = `/api/v1/testimonial?id=eq.${testimonial.id}`
+ const url = `${getBaseUrl()}/testimonial?id=eq.${testimonial.id}`
return fetch(url, {
method: 'PATCH',
headers: {
@@ -132,7 +141,7 @@ export async function patchTestimonialPositions({testimonials, token}: { testimo
export async function deleteTestimonialById({id, token}: { id: string, token: string }) {
try {
- const url = `/api/v1/testimonial?id=eq.${id}`
+ const url = `${getBaseUrl()}/testimonial?id=eq.${id}`
const resp = await fetch(url, {
method: 'DELETE',
headers: {
diff --git a/frontend/components/software/edit/testimonials/index.tsx b/frontend/components/software/edit/testimonials/index.tsx
index bbebb0c17..fd1fbb086 100644
--- a/frontend/components/software/edit/testimonials/index.tsx
+++ b/frontend/components/software/edit/testimonials/index.tsx
@@ -10,34 +10,28 @@ import {useState} from 'react'
import AddIcon from '@mui/icons-material/Add'
import Button from '@mui/material/Button'
-import {useSession} from '~/auth'
-import useSnackbar from '../../../snackbar/useSnackbar'
-import {NewTestimonial, Testimonial} from '../../../../types/Testimonial'
-import {
- postTestimonial, patchTestimonial,
- deleteTestimonialById, patchTestimonialPositions
-} from '../../../../utils/editTestimonial'
-import {sortOnNumProp} from '../../../../utils/sortFn'
-import ContentLoader from '../../../layout/ContentLoader'
-import ConfirmDeleteModal from '../../../layout/ConfirmDeleteModal'
-
-import EditTestimonialModal from './EditTestimonialModal'
-import EditSection from '../../../layout/EditSection'
-import EditSectionTitle from '../../../layout/EditSectionTitle'
+import {NewTestimonial, Testimonial} from '~/types/Testimonial'
+import ContentLoader from '~/components/layout/ContentLoader'
+import ConfirmDeleteModal from '~/components/layout/ConfirmDeleteModal'
+import EditSection from '~/components/layout/EditSection'
+import EditSectionTitle from '~/components/layout/EditSectionTitle'
+import {testimonialInformation as config} from '../editSoftwareConfig'
import {ModalProps,ModalStates} from '../editSoftwareTypes'
-
+import EditTestimonialModal from './EditTestimonialModal'
import SortableTestimonialList from './SortableTestimonialList'
-import useTestimonals from './useTestimonials'
-
+import useTestimonals from './useSoftwareTestimonials'
type EditTestimonialModal = ModalProps & {
testimonial?: NewTestimonial | Testimonial
}
export default function SoftwareTestimonials() {
- const {token} = useSession()
- const {showErrorMessage, showSuccessMessage} = useSnackbar()
- const {loading,software,testimonials,setTestimonials} = useTestimonals()
+ const {
+ loading,software,testimonials,
+ addTestimonial,updateTestimonial,
+ sortedTestimonials,deleteTestimonial
+ } = useTestimonals()
+
const [modal, setModal] = useState>({
edit: {
open: false,
@@ -57,26 +51,6 @@ export default function SoftwareTestimonials() {
// console.log("loading...", loading)
// console.groupEnd()
- function updateTestimonialList({data, pos}: { data: Testimonial, pos?: number }) {
- if (typeof pos == 'number') {
- // REPLACE existing item and sort
- const list = [
- ...testimonials.slice(0, pos),
- data,
- ...testimonials.slice(pos+1)
- ].sort((a,b)=>sortOnNumProp(a,b,'position'))
- // pass new list with addition contributor
- setTestimonials(list)
- } else {
- // ADD item and sort
- const list = [
- ...testimonials,
- data
- ].sort((a,b)=>sortOnNumProp(a,b,'position'))
- setTestimonials(list)
- }
- }
-
function loadTestimonialIntoModal(testimonial:NewTestimonial|Testimonial,pos?:number) {
setModal({
edit: {
@@ -105,7 +79,6 @@ export default function SoftwareTestimonials() {
// console.log('Add new testimonial')
loadTestimonialIntoModal({
id: null,
- software: software?.id ?? '',
message: null,
source: null,
position: testimonials.length + 1
@@ -123,29 +96,13 @@ export default function SoftwareTestimonials() {
}
async function onSubmitTestimonial({data,pos}:{data:Testimonial|NewTestimonial,pos?:number}) {
- // debugger
closeModals()
- // if id present we update
- if (data?.id) {
- const resp = await patchTestimonial({testimonial: data as Testimonial, token})
- // debugger
- if (resp.status === 200) {
- updateTestimonialList({data:data as Testimonial,pos})
- } else {
- showErrorMessage(`Failed to update testimonial. Error: ${resp.message}`)
- }
- } else {
+ if (data?.id === null) {
// new testimonial
- const resp = await postTestimonial({testimonial: data as NewTestimonial, token})
- // debugger
- if (resp.status === 201) {
- // we receive processed item as message
- const record = resp.message
- updateTestimonialList({data: record, pos})
- showSuccessMessage(`Created new testimonial for ${software.brand_name}!`)
- } else {
- showErrorMessage(`Failed to add testimonial. Error: ${resp.message}`)
- }
+ addTestimonial(data)
+ } else if (typeof pos === 'number') {
+ // if id present we update
+ updateTestimonial({data: data as Testimonial, pos})
}
}
@@ -163,53 +120,6 @@ export default function SoftwareTestimonials() {
})
}
- async function deleteTestimonial(pos?:number) {
- if (typeof pos=='undefined') return
- closeModals()
- // debugger
- const testimonial = testimonials[pos]
- if (testimonial?.id) {
- const resp = await deleteTestimonialById({id: testimonial?.id ?? '', token})
- // console.log('deleteTestimonial...resp...', resp)
- // debugger
- if (resp.status === 200) {
- // remove item from the list
- const list = [
- ...testimonials.slice(0, pos),
- ...testimonials.slice(pos+1)
- ].map((item,pos) => {
- item.position = pos + 1
- return item
- })
- // patch testimonials
- await sortedTestimonials(list)
- } else {
- showErrorMessage(`Failed to remove testimonial! Error: ${resp.message}`)
- }
- }
- }
-
- async function sortedTestimonials(newList: Testimonial[]) {
- if (newList.length > 0) {
- // update ui first
- setTestimonials(newList)
- // update db
- const resp = await patchTestimonialPositions({
- testimonials: newList,
- token
- })
- if (resp.status !== 200) {
- // revert back
- setTestimonials(testimonials)
- // show error
- showErrorMessage(`Failed to update testimonial positions! Error: ${resp.message}`)
- }
- } else {
- // reset list
- setTestimonials([])
- }
- }
-
function getTestimonialSubtitle() {
if (testimonials?.length === 1) {
return `${software?.brand_name} has 1 testimonial`
@@ -242,22 +152,37 @@ export default function SoftwareTestimonials() {
/>
-
- Are you sure you want to remove testimonial from source {modal.delete.displayName ?? ''}?
- }
- onCancel={closeModals}
- onDelete={()=>deleteTestimonial(modal.delete.pos)}
- />
+
+ {
+ modal.edit.open ?
+
+ : null
+ }
+
+ {modal.delete.open ?
+ Are you sure you want to remove testimonial from source {modal.delete.displayName ?? ''}?
+ }
+ onCancel={closeModals}
+ onDelete={()=> {
+ if (typeof modal.delete.pos === 'number'){
+ deleteTestimonial(modal.delete.pos)
+ }
+ closeModals()
+ }}
+ />
+ : null
+ }
)
}
diff --git a/frontend/components/software/edit/testimonials/useSoftwareTestimonials.tsx b/frontend/components/software/edit/testimonials/useSoftwareTestimonials.tsx
new file mode 100644
index 000000000..88da5c316
--- /dev/null
+++ b/frontend/components/software/edit/testimonials/useSoftwareTestimonials.tsx
@@ -0,0 +1,150 @@
+// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
+// SPDX-FileCopyrightText: 2022 dv4all
+// SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
+// SPDX-FileCopyrightText: 2024 Netherlands eScience Center
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import {useCallback, useEffect, useState} from 'react'
+
+import {useSession} from '~/auth'
+import {sortOnNumProp} from '~/utils/sortFn'
+import {NewTestimonial, Testimonial} from '~/types/Testimonial'
+import useSnackbar from '~/components/snackbar/useSnackbar'
+import useSoftwareContext from '../useSoftwareContext'
+import {
+ deleteTestimonialById, getTestimonialsForSoftware,
+ patchTestimonial, patchTestimonialPositions,
+ postTestimonial
+} from './apiSoftwareTestimonial'
+
+
+export default function useSoftwareTestimonals() {
+ const {token} = useSession()
+ const {software} = useSoftwareContext()
+ const {showErrorMessage} = useSnackbar()
+ const [testimonials, setTestimonials] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [loadedSoftware, setLoadedSoftware] = useState('')
+
+ const addTestimonial = useCallback(async(item:NewTestimonial)=>{
+ if (software.id && token){
+ const testimonial = {
+ ...item,
+ software: software.id
+ }
+ const resp = await postTestimonial({
+ testimonial,
+ token
+ })
+ // debugger
+ if (resp.status === 201) {
+ setTestimonials((data)=>{
+ return [
+ ...data,
+ resp.message
+ ]
+ })
+ } else {
+ showErrorMessage(`Failed to add testimonial. Error: ${resp.message}`)
+ }
+ }
+ // ignore showErrorMessage as dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[software.id,token])
+
+ const updateTestimonial = useCallback(async({data, pos}: { data: Testimonial, pos: number })=>{
+ if (typeof pos == 'number' && data.id) {
+ const testimonial = {
+ ...data,
+ software: software.id
+ }
+ const resp = await patchTestimonial({testimonial, token})
+ if (resp.status === 200) {
+ setTestimonials((testimonials)=>{
+ // replace item in state
+ const list = [
+ ...testimonials.slice(0, pos),
+ testimonial,
+ ...testimonials.slice(pos+1)
+ ].sort((a,b)=>sortOnNumProp(a,b,'position'))
+ return list
+ })
+ } else {
+ showErrorMessage(`Failed to update testimonial. Error: ${resp.message}`)
+ }
+ }
+ // ignore showErrorMessage as dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[software.id,token])
+
+ const deleteTestimonial = useCallback(async(pos:number)=>{
+ const id = testimonials[pos].id
+ if (id){
+ const resp = await deleteTestimonialById({id, token})
+ if (resp.status === 200) {
+ // remove item from the list
+ const list = testimonials
+ .filter(item=>item.id!==id)
+ .map((item,pos) => {
+ item.position = pos + 1
+ return item
+ })
+ // patch testimonials position
+ await sortedTestimonials(list)
+ } else {
+ showErrorMessage(`Failed to remove testimonial! Error: ${resp.message}`)
+ }
+ }
+ // ignore showErrorMessage as dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,testimonials])
+
+ const sortedTestimonials = useCallback(async(newList:Testimonial[])=>{
+ // update ui first
+ setTestimonials(newList)
+ // update db
+ const resp = await patchTestimonialPositions({
+ testimonials: newList,
+ token
+ })
+ if (resp.status !== 200) {
+ // revert back
+ setTestimonials(testimonials)
+ // show error
+ showErrorMessage(`Failed to update testimonial positions! Error: ${resp.message}`)
+ }
+ // ignore showErrorMessage as dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },[token,testimonials])
+
+ useEffect(() => {
+ let abort = false
+ const getTestimonials = async (software:string,token:string) => {
+ const resp = await getTestimonialsForSoftware({
+ software,
+ token
+ })
+ if (abort) return
+ // update state
+ setTestimonials(resp ?? [])
+ setLoadedSoftware(software)
+ setLoading(false)
+ }
+ if (software?.id && token &&
+ software?.id !== loadedSoftware) {
+ getTestimonials(software.id,token)
+ }
+ return () => { abort = true }
+ },[software?.id,token,loadedSoftware])
+
+ return {
+ loading,
+ testimonials,
+ software,
+ addTestimonial,
+ updateTestimonial,
+ sortedTestimonials,
+ deleteTestimonial
+ }
+}
diff --git a/frontend/components/software/edit/testimonials/useTestimonials.tsx b/frontend/components/software/edit/testimonials/useTestimonials.tsx
deleted file mode 100644
index 0fe8e8e2a..000000000
--- a/frontend/components/software/edit/testimonials/useTestimonials.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
-// SPDX-FileCopyrightText: 2022 dv4all
-//
-// SPDX-License-Identifier: Apache-2.0
-
-import {useEffect, useState} from 'react'
-import {useSession} from '~/auth'
-import {Testimonial} from '~/types/Testimonial'
-import {getTestimonialsForSoftware} from '~/utils/editTestimonial'
-import useSoftwareContext from '../useSoftwareContext'
-
-export default function useTestimonals() {
- const {token} = useSession()
- const {software} = useSoftwareContext()
- const [testimonials, setTestimonials] = useState([])
- const [loading, setLoading] = useState(true)
- const [loadedSoftware, setLoadedSoftware] = useState('')
-
- useEffect(() => {
- let abort = false
- const getTestimonials = async (software:string,token:string) => {
- const resp = await getTestimonialsForSoftware({
- software,
- token,
- frontend:true
- })
- if (abort) return
- // update state
- setTestimonials(resp ?? [])
- setLoadedSoftware(software)
- setLoading(false)
- }
- if (software?.id && token &&
- software?.id !== loadedSoftware) {
- getTestimonials(software.id,token)
- }
- return () => { abort = true }
- },[software?.id,token,loadedSoftware])
-
- return {
- loading,
- testimonials,
- setTestimonials,
- software
- }
-}
diff --git a/frontend/pages/projects/[slug]/index.tsx b/frontend/pages/projects/[slug]/index.tsx
index d4cdef42b..1e834d750 100644
--- a/frontend/pages/projects/[slug]/index.tsx
+++ b/frontend/pages/projects/[slug]/index.tsx
@@ -26,6 +26,7 @@ import {MentionItemProps} from '~/types/Mention'
import {Person} from '~/types/Contributor'
import {ProjectOrganisationProps} from '~/types/Organisation'
import {SoftwareOverviewItemProps} from '~/types/SoftwareTypes'
+import {Testimonial} from '~/types/Testimonial'
import AppHeader from '~/components/AppHeader'
import AppFooter from '~/components/AppFooter'
import EditPageButton from '~/components/layout/EditPageButton'
@@ -42,6 +43,8 @@ import RelatedSoftwareSection from '~/components/software/RelatedSoftwareSection
import ProjectInfo from '~/components/projects/ProjectInfo'
import RelatedProjectsSection from '~/components/projects/RelatedProjectsSection'
import MentionsSection from '~/components/mention/MentionsSection'
+import {getTestimonialsForProject} from '~/components/projects/edit/testimonials/apiProjectTestimonial'
+import TestimonialSection from '~/components/software/TestimonialsSection'
export interface ProjectPageProps extends ScriptProps{
slug: string
@@ -53,6 +56,7 @@ export interface ProjectPageProps extends ScriptProps{
links: ProjectLink[],
output: MentionItemProps[],
impact: MentionItemProps[],
+ testimonials: Testimonial[]
team: Person[],
relatedSoftware: SoftwareOverviewItemProps[],
relatedProjects: RelatedProject[]
@@ -61,13 +65,13 @@ export interface ProjectPageProps extends ScriptProps{
export default function ProjectPage(props: ProjectPageProps) {
const {slug, project, isMaintainer, organisations,
researchDomains, keywords, links, output, impact, team,
- relatedSoftware, relatedProjects
+ relatedSoftware, relatedProjects, testimonials
} = props
if (!project?.title){
return
}
- // console.log('ProjectPage...output...', output)
+ // console.log('ProjectPage...testimonials...', testimonials)
return (
<>
{/* Page Head meta tags */}
@@ -124,6 +128,10 @@ export default function ProjectPage(props: ProjectPageProps) {
mentions={output}
/>
+ {/* Testimonials (uses software components) */}
+
{/* Team (uses software components) */}