diff --git a/packages/web-app/cypress/e2e/associate-document-preview.cy.js b/packages/web-app/cypress/e2e/associate-document-preview.cy.js new file mode 100644 index 000000000..7a8556f21 --- /dev/null +++ b/packages/web-app/cypress/e2e/associate-document-preview.cy.js @@ -0,0 +1,199 @@ +describe('Previewing a document before association', () => { + beforeEach(() => { + cy.mockApiCatchAll(); + cy.intercept( + { method: 'GET', pathname: '/api/v1/entrances/42' }, + { + body: { + id: 42, + name: 'Test entrance', + isDeleted: false, + documents: [], + massifs: [], + locations: [], + descriptions: [], + riggings: [], + histories: [], + comments: [] + } + } + ); + cy.intercept( + { method: 'GET', pathname: '/api/v1/documents/types' }, + { + body: { documentTypes: [] } + } + ); + cy.intercept( + { method: 'GET', pathname: '/api/v1/documents/subjects' }, + { + body: { subjects: [] } + } + ); + cy.intercept( + { method: 'GET', pathname: '/api/v1/languages' }, + { + body: { languages: [] } + } + ); + cy.intercept( + { method: 'GET', pathname: '/api/v1/licenses' }, + { + body: { licenses: [] } + } + ); + cy.intercept( + { method: 'POST', pathname: '/api/v1/advanced-search' }, + { + body: { + results: [ + { + id: 7, + type: 'Article', + title: 'Preview document', + description: 'Document to inspect before association', + authors: [] + } + ], + totalResults: 1 + } + } + ).as('searchDocuments'); + cy.intercept( + { method: 'GET', pathname: '/api/v1/documents/7' }, + { + body: { + id: 7, + title: 'Preview document', + type: 'Article', + files: [], + authors: [], + authorsOrganization: [], + massifs: [], + entrances: [], + isDeleted: false, + isValidated: true + } + } + ).as('getDocument'); + cy.intercept( + { method: 'GET', pathname: '/api/v1/documents/7/children' }, + { + body: { documents: [] } + } + ); + }); + + it('keeps preview and selection separate in table and card views', () => { + cy.loginAs(); + cy.visit('/entrances/42?tab=documents', { + onBeforeLoad: win => win.localStorage.setItem('selectedLanguage', 'en') + }); + cy.get('[data-testid="associate-documents-button"]', { + timeout: 30000 + }).click(); + cy.get('input[placeholder="Search for a document..."]') + .closest('form') + .find('button[type="submit"]') + .click(); + cy.wait('@searchDocuments'); + + cy.contains('tr', 'Preview document').click(); + cy.wait('@getDocument'); + cy.contains('[role="dialog"]', 'Detailed document view').should( + 'contain.text', + 'Preview document' + ); + cy.contains('[role="dialog"]', 'Detailed document view') + .find('[aria-label="close"]') + .click(); + cy.contains('[role="dialog"]', 'Detailed document view').should( + 'not.exist' + ); + cy.contains('button', 'Associate 0 documents').should('be.disabled'); + + cy.get('[aria-label="Card view"]').filter(':visible').first().click(); + cy.viewport(390, 844); + cy.get('[aria-label="Preview"]').click(); + cy.contains('[role="dialog"]', 'Detailed document view').should( + 'contain.text', + 'Preview document' + ); + cy.contains('[role="dialog"]', 'Detailed document view') + .find('[aria-label="close"]') + .click(); + cy.contains('[role="dialog"]', 'Detailed document view').should( + 'not.exist' + ); + cy.contains('button', 'Associate 0 documents').should('be.disabled'); + + cy.clock(null, ['setTimeout', 'clearTimeout']); + cy.contains('button', 'Preview document') + .find('input[type="checkbox"]') + .trigger('touchstart', { + force: true, + touches: [{ clientX: 10, clientY: 10 }] + }); + cy.tick(600); + cy.contains('[role="dialog"]', 'Detailed document view').should( + 'not.exist' + ); + cy.contains('button', 'Preview document') + .find('input[type="checkbox"]') + .trigger('touchend', { force: true }); + cy.contains('button', 'Preview document') + .find('input[type="checkbox"]') + .click({ force: true }); + cy.contains('button', 'Associate 1 document').should('be.enabled'); + cy.contains('button', 'Preview document') + .find('input[type="checkbox"]') + .click({ force: true }); + cy.clock().invoke('restore'); + + cy.contains('Preview document').click(); + cy.contains('button', 'Associate 1 document').should('be.enabled'); + }); + + it('closes entrance association when a document is soft-deleted', () => { + let isDeleted = false; + cy.intercept({ method: 'GET', pathname: '/api/v1/documents/7' }, request => + request.reply({ + id: 7, + title: 'Preview document', + type: 'Article', + files: [], + authors: [], + authorsOrganization: [], + massifs: [], + entrances: [{ id: 42, name: 'Test entrance' }], + isDeleted, + isValidated: true + }) + ); + cy.intercept( + { method: 'DELETE', pathname: '/api/v1/documents/7' }, + request => { + isDeleted = true; + request.reply({ statusCode: 200, body: {} }); + } + ).as('deleteDocument'); + cy.loginAs({ groups: ['User', 'Moderator'] }); + cy.visit('/documents/7', { + onBeforeLoad: win => win.localStorage.setItem('selectedLanguage', 'en') + }); + cy.get('[data-testid="associate-entrances-button"]', { + timeout: 30000 + }).click(); + cy.get('input[placeholder="Entrance name"]').should('exist'); + + cy.get('[aria-label="Delete"]').click(); + cy.contains('[role="dialog"]', 'Deletion confirmation') + .contains('button', /^Delete$/) + .click(); + cy.wait('@deleteDocument'); + + cy.get('[data-testid="associate-entrances-button"]').should('not.exist'); + cy.get('input[placeholder="Entrance name"]').should('not.exist'); + cy.contains('Test entrance').should('exist'); + }); +}); diff --git a/packages/web-app/public/lang/ar.json b/packages/web-app/public/lang/ar.json index 8a9b092d9..157846ab9 100644 --- a/packages/web-app/public/lang/ar.json +++ b/packages/web-app/public/lang/ar.json @@ -331,6 +331,7 @@ "An article from a source": "مقالة من مصدر", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "تم إرسال بريد إلكتروني إلى {email} للتحقق من حسابك. يرجى النقر على الرابط الموجود في البريد الإلكتروني لتفعيل حسابك.", "An email will be sent to:": "سيتم إرسال بريد إلكتروني إلى:", + "An entity cannot redirect to itself.": "لا يمكن إعادة التوجيه من كيان إلى نفسه.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "حدث خطأ عند إنشاء كتلة جبلية.", "An error occurred when creating a network.": "حدث خطأ عند إنشاء شبكة.", @@ -419,7 +420,9 @@ "Assign an existing document": "تعيين وثيقة موجودة", "Associate": "ربط", "Associate {nb} documents": "ربط {nb} وثيقة", + "Associate {nb} entrances": "ربط {nb} من المداخل", "Associate 1 document": "ربط وثيقة واحدة", + "Associate 1 entrance": "ربط مدخل واحد", "Associate to an entrance": "ربط بمدخل", "at": "في", "At least one polygon must not be marked as a hole.": "يجب ألا يُعلَّم ما لا يقل عن مضلع واحد كفجوة.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "لا توجد ملفات مرفقة بهذا المستند.", "No interest": "غير مثير للاهتمام", "No issues in this collection.": "لا توجد أعداد في هذه المجموعة.", + "No linked entities.": "لا توجد كيانات مرتبطة.", "No massif subscriptions": "لا توجد اشتراكات للكتلة الجبلية", "No modifications allowed": "لا يُسمح بأي تعديلات", "no name": "بدون اسم", @@ -2353,7 +2357,8 @@ "Select an authorization document": "حدد مستند التخويل", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "اختر منظمة موجودة من القائمة، وإلا فسيتم إنشاء منظمة جديدة باسم «{name}».", "Select an identifier type": "حدد نوع المعرف", - "Select document(s) by clicking on the result table above.": "حدد المستند(ات) بالنقر على جدول النتائج أعلاه.", + "Select documents with the checkboxes. Preview any document before associating it.": "حدّد المستندات باستخدام مربعات الاختيار. عاين أي مستند قبل ربطه.", + "Select entrances with the checkboxes to associate them.": "حدد المداخل باستخدام مربعات الاختيار لربطها.", "Select the attributes that apply to this entrance": "حدد الخصائص التي تنطبق على هذا المدخل", "Selected": "محدد", "Semi-major radius: ": "نصف القطر شبه الرئيسي: ", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "تعذّر عرض ملف PDF هذا.", "Unable to load recent changes": "تعذر تحميل التغييرات الأخيرة", "Unable to open the statistics dashboard. Please try again.": "تعذّر فتح لوحة الإحصاءات. يرجى المحاولة مرة أخرى.", + "Unable to search for a replacement. Please try again.": "تعذّر البحث عن كيان بديل. يرجى المحاولة مرة أخرى.", "Unarchive": "إلغاء الأرشفة", "Underground time": "الوقت تحت الأرض", "Underground time (short)": "تحت الأرض", diff --git a/packages/web-app/public/lang/bg.json b/packages/web-app/public/lang/bg.json index 31e9fd621..899e78859 100644 --- a/packages/web-app/public/lang/bg.json +++ b/packages/web-app/public/lang/bg.json @@ -331,6 +331,7 @@ "An article from a source": "Статия от източник", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "На {email} е изпратен имейл за проверка на вашия акаунт. Моля, щракнете на връзката в имейла, за да активирате вашия акаунт.", "An email will be sent to:": "Ще бъде изпратен имейл до:", + "An entity cannot redirect to itself.": "Обектът не може да пренасочва към себе си.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "Възникна грешка при създаването на масив.", "An error occurred when creating a network.": "Възникна грешка при създаването на мрежа.", @@ -419,7 +420,9 @@ "Assign an existing document": "Присвояване на съществуващ документ", "Associate": "Сътрудник", "Associate {nb} documents": "Свързване на {nb} документа", + "Associate {nb} entrances": "Свързване на {nb} входа", "Associate 1 document": "Свързване на 1 документ", + "Associate 1 entrance": "Свързване на 1 вход", "Associate to an entrance": "Свържете с вход", "at": "в", "At least one polygon must not be marked as a hole.": "Поне един полигон не трябва да бъде маркиран като дупка.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "Към този документ не са прикачени файлове.", "No interest": "Без интерес", "No issues in this collection.": "Няма броеве в тази колекция.", + "No linked entities.": "Няма свързани обекти.", "No massif subscriptions": "Няма абонаменти за масив", "No modifications allowed": "Не се разрешават промени", "no name": "без име", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Изберете документ за разрешение", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Изберете съществуваща организация от списъка, в противен случай ще бъде създадена нова с име „{name}“.", "Select an identifier type": "Изберете тип идентификатор", - "Select document(s) by clicking on the result table above.": "Изберете документ(и), като щракнете върху таблицата с резултати по-горе.", + "Select documents with the checkboxes. Preview any document before associating it.": "Изберете документи с квадратчетата за отметка. Прегледайте всеки документ, преди да го свържете.", + "Select entrances with the checkboxes to associate them.": "Изберете входовете с отметките, за да ги свържете.", "Select the attributes that apply to this entrance": "Изберете характеристиките, които се отнасят за този вход", "Selected": "Избрано", "Semi-major radius: ": "Голяма полуос", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Този PDF файл не може да бъде показан.", "Unable to load recent changes": "Скорошните промени не могат да бъдат заредени", "Unable to open the statistics dashboard. Please try again.": "Неуспешно отваряне на таблото със статистики. Моля, опитайте отново.", + "Unable to search for a replacement. Please try again.": "Неуспешно търсене на заместващ обект. Моля, опитайте отново.", "Unarchive": "Разархивиране", "Underground time": "Време под земята", "Underground time (short)": "Под земята", diff --git a/packages/web-app/public/lang/ca.json b/packages/web-app/public/lang/ca.json index c6fe9853f..a38ebd412 100644 --- a/packages/web-app/public/lang/ca.json +++ b/packages/web-app/public/lang/ca.json @@ -331,6 +331,7 @@ "An article from a source": "Un article d'una font", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "S'ha enviat un correu a {email} per verificar el teu compte. Fes clic a l'enllaç del correu per activar el teu compte.", "An email will be sent to:": "S'enviarà un correu electrònic a:", + "An entity cannot redirect to itself.": "Una entitat no pot redirigir a si mateixa.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "S'ha produït un error en crear un massís.", "An error occurred when creating a network.": "S'ha produït un error en crear una xarxa.", @@ -419,7 +420,9 @@ "Assign an existing document": "Assignar un document existent", "Associate": "Associar", "Associate {nb} documents": "Associar {nb} documents", + "Associate {nb} entrances": "Associar {nb} entrades", "Associate 1 document": "Associar 1 document", + "Associate 1 entrance": "Associar 1 entrada", "Associate to an entrance": "Associa a una entrada", "at": "a", "At least one polygon must not be marked as a hole.": "Almenys un polígon no ha d'estar marcat com a forat.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "No hi ha fitxers adjunts a aquest document.", "No interest": "Sense interès", "No issues in this collection.": "No hi ha números en aquesta col·lecció.", + "No linked entities.": "No hi ha entitats vinculades.", "No massif subscriptions": "Sense subscripcions al massís", "No modifications allowed": "No es permeten modificacions", "no name": "sense nom", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Seleccioneu un document d'autorització", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Seleccioneu una organització existent de la llista; en cas contrari, se'n crearà una de nova anomenada «{name}».", "Select an identifier type": "Selecciona un tipus d'identificador", - "Select document(s) by clicking on the result table above.": "Seleccioneu els documents fent clic a la taula de resultats de dalt.", + "Select documents with the checkboxes. Preview any document before associating it.": "Seleccioneu els documents amb les caselles de selecció. Previsualitzeu qualsevol document abans d'associar-lo.", + "Select entrances with the checkboxes to associate them.": "Seleccioneu les entrades amb les caselles per associar-les.", "Select the attributes that apply to this entrance": "Seleccioneu els atributs que s'apliquen a aquesta entrada", "Selected": "Escollit", "Semi-major radius: ": "Semieix major :", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "No es pot mostrar aquest PDF.", "Unable to load recent changes": "No s'han pogut carregar els canvis recents", "Unable to open the statistics dashboard. Please try again.": "No s'ha pogut obrir el tauler d'estadístiques. Torneu-ho a provar.", + "Unable to search for a replacement. Please try again.": "No s'ha pogut cercar una entitat de substitució. Torneu-ho a provar.", "Unarchive": "Desarxivar", "Underground time": "Moment de ficar-se sota terra", "Underground time (short)": "Sota terra", diff --git a/packages/web-app/public/lang/de.json b/packages/web-app/public/lang/de.json index 0b0d3062c..3094886e6 100644 --- a/packages/web-app/public/lang/de.json +++ b/packages/web-app/public/lang/de.json @@ -331,6 +331,7 @@ "An article from a source": "Ein Artikel aus einer Quelle", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "Eine E-Mail wurde an {email} gesendet, um Ihr Konto zu verifizieren. Bitte klicken Sie auf den Link in der E-Mail, um Ihr Konto zu aktivieren.", "An email will be sent to:": "Eine E-Mail wird gesendet an:", + "An entity cannot redirect to itself.": "Eine Entität kann nicht auf sich selbst weiterleiten.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "Es existiert bereits ein Eingang mit einem ähnlichen Namen. Bitte prüfen Sie, ob es sich um dieselbe Höhle handelt, bevor Sie einen neuen erstellen.", "An error occurred when creating a massif.": "Beim Erstellen eines Massivs ist ein Fehler aufgetreten.", "An error occurred when creating a network.": "Beim Erstellen eines Netzwerks ist ein Fehler aufgetreten.", @@ -419,7 +420,9 @@ "Assign an existing document": "Ein vorhandenes Dokument zuweisen", "Associate": "Verknüpfen", "Associate {nb} documents": "{nb} Dokumente verknüpfen", + "Associate {nb} entrances": "{nb} Eingänge zuordnen", "Associate 1 document": "1 Dokument verknüpfen", + "Associate 1 entrance": "1 Eingang zuordnen", "Associate to an entrance": "Mit einem Eingang verknüpfen", "at": "um", "At least one polygon must not be marked as a hole.": "Mindestens ein Polygon darf nicht als Loch markiert sein.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "An diesem Dokument sind keine Dateien angehängt.", "No interest": "Nicht interessant", "No issues in this collection.": "Keine Ausgaben in dieser Sammlung.", + "No linked entities.": "Keine verknüpften Einträge vorhanden.", "No massif subscriptions": "Keine Gebirgsabonnements", "No modifications allowed": "Keine Bearbeitungen erlaubt", "no name": "kein Name", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Wählen Sie ein Autorisierungsdokument", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Wählen Sie eine bestehende Organisation aus der Liste, andernfalls wird eine neue namens „{name}“ erstellt.", "Select an identifier type": "Identifikatortyp auswählen", - "Select document(s) by clicking on the result table above.": "Wählen Sie Dokument(e) aus, indem Sie auf die Ergebnistabelle oben klicken.", + "Select documents with the checkboxes. Preview any document before associating it.": "Wählen Sie Dokumente über die Kontrollkästchen aus. Sehen Sie sich ein Dokument vor der Zuordnung in der Vorschau an.", + "Select entrances with the checkboxes to associate them.": "Wähle die Eingänge über die Kontrollkästchen aus, um sie zuzuordnen.", "Select the attributes that apply to this entrance": "Wählen Sie die Merkmale aus, die auf diesen Eingang zutreffen", "Selected": "Ausgewählt", "Semi-major radius: ": "Semi-Dur Radius:", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Dieses PDF kann nicht angezeigt werden.", "Unable to load recent changes": "Letzte Änderungen konnten nicht geladen werden", "Unable to open the statistics dashboard. Please try again.": "Das Statistik-Dashboard konnte nicht geöffnet werden. Bitte versuchen Sie es erneut.", + "Unable to search for a replacement. Please try again.": "Die Suche nach einer Ersatzentität ist fehlgeschlagen. Bitte versuchen Sie es erneut.", "Unarchive": "Dearchivieren", "Underground time": "Unterirdische Zeit", "Underground time (short)": "Unter Tage", diff --git a/packages/web-app/public/lang/el.json b/packages/web-app/public/lang/el.json index ca7688d3f..0df156831 100644 --- a/packages/web-app/public/lang/el.json +++ b/packages/web-app/public/lang/el.json @@ -331,6 +331,7 @@ "An article from a source": "Άρθρο από πηγή", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "Ένα email αποστάλη στο {email} για την επαλήθευση του λογαριασμού σας. Παρακαλώ κάντε κλικ στο σύνδεσμο στο email για να ενεργοποιήσετε τον λογαριασμό σας.", "An email will be sent to:": "Θα σταλεί email στο:", + "An entity cannot redirect to itself.": "Μια οντότητα δεν μπορεί να ανακατευθύνει στον εαυτό της.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "Παρουσιάστηκε σφάλμα κατά τη δημιουργία μαζικής περιοχής.", "An error occurred when creating a network.": "Παρουσιάστηκε σφάλμα κατά τη δημιουργία δικτύου.", @@ -419,7 +420,9 @@ "Assign an existing document": "Ανάθεση υπάρχοντος εγγράφου", "Associate": "Συσχέτιση", "Associate {nb} documents": "Συσχέτιση {nb} εγγράφων", + "Associate {nb} entrances": "Συσχέτιση {nb} εισόδων", "Associate 1 document": "Συσχέτιση 1 εγγράφου", + "Associate 1 entrance": "Συσχέτιση 1 εισόδου", "Associate to an entrance": "Σύνδεση με είσοδο", "at": "στις", "At least one polygon must not be marked as a hole.": "Τουλάχιστον ένα πολύγωνο δεν πρέπει να επισημανθεί ως τρύπα.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "Δεν υπάρχουν αρχεία συνημμένα σε αυτό το έγγραφο.", "No interest": "Χωρίς ενδιαφέρον", "No issues in this collection.": "Δεν υπάρχουν τεύχη σε αυτή τη συλλογή.", + "No linked entities.": "Δεν υπάρχουν συνδεδεμένες οντότητες.", "No massif subscriptions": "Χωρίς συνδρομές μαζίφ", "No modifications allowed": "Δεν επιτρέπονται τροποποιήσεις", "no name": "χωρίς όνομα", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Επιλέξτε έγγραφο εξουσιοδότησης", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Επιλέξτε έναν υπάρχοντα οργανισμό από τη λίστα, διαφορετικά θα δημιουργηθεί ένας νέος με το όνομα «{name}».", "Select an identifier type": "Επιλέξτε τύπο αναγνωριστικού", - "Select document(s) by clicking on the result table above.": "Επιλέξτε έγγραφο(-α) κάνοντας κλικ στον παραπάνω πίνακα αποτελεσμάτων.", + "Select documents with the checkboxes. Preview any document before associating it.": "Επιλέξτε έγγραφα με τα πλαίσια επιλογής. Κάντε προεπισκόπηση οποιουδήποτε εγγράφου πριν το συσχετίσετε.", + "Select entrances with the checkboxes to associate them.": "Επιλέξτε τις εισόδους με τα πλαίσια επιλογής για να τις συσχετίσετε.", "Select the attributes that apply to this entrance": "Επιλέξτε τα χαρακτηριστικά που ισχύουν για αυτήν την είσοδο", "Selected": "Επιλεγμένο", "Semi-major radius: ": "Ημι-μείζων ακτίνα: ", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Δεν είναι δυνατή η προβολή αυτού του PDF.", "Unable to load recent changes": "Δεν ήταν δυνατή η φόρτωση των πρόσφατων αλλαγών", "Unable to open the statistics dashboard. Please try again.": "Δεν ήταν δυνατό το άνοιγμα του πίνακα στατιστικών. Δοκιμάστε ξανά.", + "Unable to search for a replacement. Please try again.": "Δεν ήταν δυνατή η αναζήτηση οντότητας αντικατάστασης. Δοκιμάστε ξανά.", "Unarchive": "Κατάργηση αρχειοθέτησης", "Underground time": "Υπόγειος χρόνος", "Underground time (short)": "Υπόγεια", diff --git a/packages/web-app/public/lang/en.json b/packages/web-app/public/lang/en.json index df40d1e03..0bbe9ed56 100644 --- a/packages/web-app/public/lang/en.json +++ b/packages/web-app/public/lang/en.json @@ -331,6 +331,7 @@ "An article from a source": "An article from a source", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.", "An email will be sent to:": "An email will be sent to:", + "An entity cannot redirect to itself.": "An entity cannot redirect to itself.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "An error occurred when creating a massif.", "An error occurred when creating a network.": "An error occurred when creating a network.", @@ -419,7 +420,9 @@ "Assign an existing document": "Assign an existing document", "Associate": "Associate", "Associate {nb} documents": "Associate {nb} documents", + "Associate {nb} entrances": "Associate {nb} entrances", "Associate 1 document": "Associate 1 document", + "Associate 1 entrance": "Associate 1 entrance", "Associate to an entrance": "Associate to an entrance", "at": "at", "At least one polygon must not be marked as a hole.": "At least one polygon must not be marked as a hole.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "No files attached to this document.", "No interest": "No interest", "No issues in this collection.": "No issues in this collection.", + "No linked entities.": "No linked entities.", "No massif subscriptions": "No massif subscriptions", "No modifications allowed": "No modifications allowed", "no name": "no name", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Select an authorization document", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Select an existing organization from the list, or a new one named \"{name}\" will be created.", "Select an identifier type": "Select an identifier type", - "Select document(s) by clicking on the result table above.": "Select document(s) by clicking on the result table above.", + "Select documents with the checkboxes. Preview any document before associating it.": "Select documents with the checkboxes. Preview any document before associating it.", + "Select entrances with the checkboxes to associate them.": "Select entrances with the checkboxes to associate them.", "Select the attributes that apply to this entrance": "Select the attributes that apply to this entrance", "Selected": "Selected", "Semi-major radius: ": "Semi-major radius: ", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Unable to display this PDF.", "Unable to load recent changes": "Unable to load recent changes", "Unable to open the statistics dashboard. Please try again.": "Unable to open the statistics dashboard. Please try again.", + "Unable to search for a replacement. Please try again.": "Unable to search for a replacement. Please try again.", "Unarchive": "Unarchive", "Underground time": "Underground time", "Underground time (short)": "Underground", diff --git a/packages/web-app/public/lang/es.json b/packages/web-app/public/lang/es.json index 3b4fe0fc3..b14ed5a66 100644 --- a/packages/web-app/public/lang/es.json +++ b/packages/web-app/public/lang/es.json @@ -331,6 +331,7 @@ "An article from a source": "Un artículo de una fuente", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "Se ha enviado un correo electrónico a {email} para verificar tu cuenta. Haz clic en el enlace del correo electrónico para activar tu cuenta.", "An email will be sent to:": "Se enviará un email a:", + "An entity cannot redirect to itself.": "Una entidad no puede redirigir a sí misma.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "Ya existe una entrada con un nombre similar. Compruebe si se trata de la misma cavidad antes de crear una nueva.", "An error occurred when creating a massif.": "Se ha producido un error al crear un macizo.", "An error occurred when creating a network.": "Ocurrió un error al crear una red.", @@ -419,7 +420,9 @@ "Assign an existing document": "Asignar un documento existente", "Associate": "Asociar", "Associate {nb} documents": "Asociar {nb} documentos", + "Associate {nb} entrances": "Asociar {nb} entradas", "Associate 1 document": "Asociar 1 documento", + "Associate 1 entrance": "Asociar 1 entrada", "Associate to an entrance": "Asociar a una entrada", "at": "en", "At least one polygon must not be marked as a hole.": "Al menos un polígono no debe estar marcado como agujero.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "No hay archivos adjuntos a este documento.", "No interest": "Sin interés", "No issues in this collection.": "No hay números en esta colección.", + "No linked entities.": "No hay entidades vinculadas.", "No massif subscriptions": "Sin suscripciones de macizo", "No modifications allowed": "No se permiten modificaciones", "no name": "sin nombre", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Seleccionar un documento de autorización", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Seleccione una organización existente de la lista; de lo contrario, se creará una nueva llamada «{name}».", "Select an identifier type": "Seleccionar un tipo de identificador", - "Select document(s) by clicking on the result table above.": "Seleccione el(los) documento(s) haciendo clic en la tabla de resultados de arriba.", + "Select documents with the checkboxes. Preview any document before associating it.": "Seleccione documentos con las casillas de verificación. Previsualice cualquier documento antes de asociarlo.", + "Select entrances with the checkboxes to associate them.": "Seleccione las entradas con las casillas para asociarlas.", "Select the attributes that apply to this entrance": "Seleccione los atributos que se aplican a esta entrada", "Selected": "Seleccionado", "Semi-major radius: ": "radio de semi-principales: ", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "No se puede mostrar este PDF.", "Unable to load recent changes": "No se pudieron cargar los cambios recientes", "Unable to open the statistics dashboard. Please try again.": "No se ha podido abrir el panel de estadísticas. Inténtalo de nuevo.", + "Unable to search for a replacement. Please try again.": "No se pudo buscar una entidad de reemplazo. Inténtalo de nuevo.", "Unarchive": "Desarchivar", "Underground time": "Tiempo bajo tierra", "Underground time (short)": "Bajo tierra", diff --git a/packages/web-app/public/lang/fr.json b/packages/web-app/public/lang/fr.json index 510f465e6..c1baf9d3b 100644 --- a/packages/web-app/public/lang/fr.json +++ b/packages/web-app/public/lang/fr.json @@ -331,6 +331,7 @@ "An article from a source": "Un article d'une ressource", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "Un email a été envoyé à {email} pour vérifier votre compte. Veuillez cliquer sur le lien dans l'email pour activer votre compte.", "An email will be sent to:": "Un mail sera envoyé à :", + "An entity cannot redirect to itself.": "Une entité ne peut pas rediriger vers elle-même.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "Une entrée portant un nom similaire existe déjà. Veuillez vérifier s'il s'agit de la même cavité avant d'en créer une nouvelle.", "An error occurred when creating a massif.": "Une erreur s'est produite lors de la création d'un massif.", "An error occurred when creating a network.": "Une erreur s'est produite lors de la création d'un réseau.", @@ -419,7 +420,9 @@ "Assign an existing document": "Associer un document existant", "Associate": "Associer", "Associate {nb} documents": "Associer {nb} documents", + "Associate {nb} entrances": "Associer {nb} entrées", "Associate 1 document": "Associer 1 document", + "Associate 1 entrance": "Associer 1 entrée", "Associate to an entrance": "Lier à une entrée", "at": "à", "At least one polygon must not be marked as a hole.": "Au moins un polygone ne doit pas être marqué comme trou.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "Aucun fichier attaché à ce document.", "No interest": "Sans intérêt", "No issues in this collection.": "Aucun numéro dans cette collection.", + "No linked entities.": "Aucune entité liée.", "No massif subscriptions": "Aucun abonnement aux massifs", "No modifications allowed": "Aucune modification autorisée", "no name": "pas de nom", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Sélectionner un document d'autorisation", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Sélectionnez une organisation existante dans la liste, sinon une nouvelle nommée « {name} » sera créée.", "Select an identifier type": "Sélectionnez un type d'identifiant", - "Select document(s) by clicking on the result table above.": "Sélectionnez le(s) document(s) en cliquant sur le tableau de résultats ci-dessus.", + "Select documents with the checkboxes. Preview any document before associating it.": "Sélectionnez les documents avec les cases à cocher. Prévisualisez un document avant de l'associer.", + "Select entrances with the checkboxes to associate them.": "Sélectionnez les entrées à associer à l'aide des cases à cocher.", "Select the attributes that apply to this entrance": "Sélectionnez les attributs qui s'appliquent à cette entrée", "Selected": "Selectionné", "Semi-major radius: ": "Demi-grand axe :", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Impossible d'afficher ce PDF.", "Unable to load recent changes": "Impossible de charger les changements récents", "Unable to open the statistics dashboard. Please try again.": "Impossible d'ouvrir le tableau de bord statistique. Veuillez réessayer.", + "Unable to search for a replacement. Please try again.": "Impossible de rechercher une entité de remplacement. Veuillez réessayer.", "Unarchive": "Désarchiver", "Underground time": "Temps passé sous terre", "Underground time (short)": "TPST", diff --git a/packages/web-app/public/lang/he.json b/packages/web-app/public/lang/he.json index 6b304fb65..9e134ea39 100644 --- a/packages/web-app/public/lang/he.json +++ b/packages/web-app/public/lang/he.json @@ -331,6 +331,7 @@ "An article from a source": "מאמר ממקור", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "נשלח אימייל אל {email} לאימות החשבון שלך. אנא לחץ על הקישור באימייל כדי להפעיל את החשבון שלך.", "An email will be sent to:": "יישלח דוא\"ל אל:", + "An entity cannot redirect to itself.": "ישות אינה יכולה להפנות אל עצמה.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "אירעה שגיאה בעת יצירת מסיב.", "An error occurred when creating a network.": "אירעה שגיאה בעת יצירת רשת.", @@ -419,7 +420,9 @@ "Assign an existing document": "הקצה מסמך קיים", "Associate": "שייך", "Associate {nb} documents": "שייך {nb} מסמכים", + "Associate {nb} entrances": "שיוך {nb} כניסות", "Associate 1 document": "שייך מסמך 1", + "Associate 1 entrance": "שיוך כניסה אחת", "Associate to an entrance": "שייך לכניסה", "at": "ב", "At least one polygon must not be marked as a hole.": "לפחות מצולע אחד לא צריך להיות מסומן כחור.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "אין קבצים מצורפים למסמך זה.", "No interest": "ללא עניין", "No issues in this collection.": "אין גיליונות באוסף זה.", + "No linked entities.": "אין ישויות מקושרות.", "No massif subscriptions": "ללא מנויים למאסיף", "No modifications allowed": "אין לבצע שינויים", "no name": "ללא שם", @@ -2353,7 +2357,8 @@ "Select an authorization document": "בחר מסמך הרשאה", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "בחר ארגון קיים מהרשימה, אחרת ייווצר ארגון חדש בשם \"{name}\".", "Select an identifier type": "בחר סוג מזהה", - "Select document(s) by clicking on the result table above.": "בחר מסמך/ים על ידי לחיצה על טבלת התוצאות למעלה.", + "Select documents with the checkboxes. Preview any document before associating it.": "בחרו מסמכים באמצעות תיבות הסימון. צפו בתצוגה מקדימה של כל מסמך לפני שיוכו.", + "Select entrances with the checkboxes to associate them.": "בחרו כניסות באמצעות תיבות הסימון כדי לשייך אותן.", "Select the attributes that apply to this entrance": "בחרו את המאפיינים החלים על כניסה זו", "Selected": "נבחר", "Semi-major radius: ": "רדיוס חצי-ראשי: ", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "לא ניתן להציג קובץ PDF זה.", "Unable to load recent changes": "לא ניתן לטעון את השינויים האחרונים", "Unable to open the statistics dashboard. Please try again.": "לא ניתן לפתוח את לוח המחוונים של הסטטיסטיקה. אנא נסו שוב.", + "Unable to search for a replacement. Please try again.": "לא ניתן לחפש ישות חלופית. יש לנסות שוב.", "Unarchive": "בטל ארכוב", "Underground time": "זמן תת-קרקעי", "Underground time (short)": "מתחת לאדמה", diff --git a/packages/web-app/public/lang/id.json b/packages/web-app/public/lang/id.json index 855e30fb3..57090f53d 100644 --- a/packages/web-app/public/lang/id.json +++ b/packages/web-app/public/lang/id.json @@ -331,6 +331,7 @@ "An article from a source": "Artikel dari sumber", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "Email telah dikirim ke {email} untuk memverifikasi akun Anda. Silakan klik tautan di email untuk mengaktifkan akun Anda.", "An email will be sent to:": "Email akan dikirim ke:", + "An entity cannot redirect to itself.": "Entitas tidak dapat mengalihkan ke dirinya sendiri.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "Terjadi kesalahan saat membuat massif.", "An error occurred when creating a network.": "Terjadi kesalahan saat membuat jaringan.", @@ -419,7 +420,9 @@ "Assign an existing document": "Tetapkan dokumen yang ada", "Associate": "Asosiasikan", "Associate {nb} documents": "Asosiasikan {nb} dokumen", + "Associate {nb} entrances": "Kaitkan {nb} pintu masuk", "Associate 1 document": "Asosiasikan 1 dokumen", + "Associate 1 entrance": "Kaitkan 1 pintu masuk", "Associate to an entrance": "Kaitkan dengan pintu masuk", "at": "pada", "At least one polygon must not be marked as a hole.": "Setidaknya satu poligon tidak boleh ditandai sebagai lubang.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "Tidak ada file yang dilampirkan ke dokumen ini.", "No interest": "Tidak menarik", "No issues in this collection.": "Tidak ada edisi dalam koleksi ini.", + "No linked entities.": "Belum ada entitas tertaut.", "No massif subscriptions": "Tidak ada langganan masif", "No modifications allowed": "Tidak boleh dimodifikasi", "no name": "tanpa nama", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Pilih dokumen otorisasi", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Pilih organisasi yang sudah ada dari daftar, atau organisasi baru bernama \"{name}\" akan dibuat.", "Select an identifier type": "Pilih jenis pengidentifikasi", - "Select document(s) by clicking on the result table above.": "Pilih dokumen dengan mengklik tabel hasil di atas.", + "Select documents with the checkboxes. Preview any document before associating it.": "Pilih dokumen menggunakan kotak centang. Pratinjau dokumen sebelum mengaitkannya.", + "Select entrances with the checkboxes to associate them.": "Pilih pintu masuk dengan kotak centang untuk mengaitkannya.", "Select the attributes that apply to this entrance": "Pilih atribut yang berlaku untuk pintu masuk ini", "Selected": "Dipilih", "Semi-major radius: ": "Radius semi-mayor: ", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Tidak dapat menampilkan PDF ini.", "Unable to load recent changes": "Tidak dapat memuat perubahan terbaru", "Unable to open the statistics dashboard. Please try again.": "Tidak dapat membuka dasbor statistik. Silakan coba lagi.", + "Unable to search for a replacement. Please try again.": "Tidak dapat mencari entitas pengganti. Silakan coba lagi.", "Unarchive": "Batal Arsip", "Underground time": "Waktu bawah tanah", "Underground time (short)": "Bawah tanah", diff --git a/packages/web-app/public/lang/it.json b/packages/web-app/public/lang/it.json index f3cd85109..e2eb10bf8 100644 --- a/packages/web-app/public/lang/it.json +++ b/packages/web-app/public/lang/it.json @@ -331,6 +331,7 @@ "An article from a source": "Un articolo da una fonte", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "Un'email è stata inviata a {email} per verificare il tuo account. Clicca il link nell'email per attivare il tuo account.", "An email will be sent to:": "Verrà inviata un'e-mail a:", + "An entity cannot redirect to itself.": "Un'entità non può reindirizzare a sé stessa.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "Esiste già un ingresso con un nome simile. Verifica se si tratta della stessa grotta prima di crearne uno nuovo.", "An error occurred when creating a massif.": "Si è verificato un errore durante la creazione di un massiccio.", "An error occurred when creating a network.": "Si è verificato un errore durante la creazione di una rete.", @@ -419,7 +420,9 @@ "Assign an existing document": "Assegna un documento esistente", "Associate": "Associa", "Associate {nb} documents": "Associa {nb} documenti", + "Associate {nb} entrances": "Associa {nb} ingressi", "Associate 1 document": "Associa 1 documento", + "Associate 1 entrance": "Associa 1 ingresso", "Associate to an entrance": "Associa a un ingresso", "at": "a", "At least one polygon must not be marked as a hole.": "Almeno un poligono non deve essere contrassegnato come buco.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "Nessun file allegato a questo documento.", "No interest": "Nessun interesse", "No issues in this collection.": "Nessun numero in questa collezione.", + "No linked entities.": "Nessuna entità collegata.", "No massif subscriptions": "Nessun abbonamento al massiccio", "No modifications allowed": "Nessuna modifica consentita", "no name": "senza nome", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Seleziona un documento di autorizzazione", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Seleziona un'organizzazione esistente dall'elenco, altrimenti ne verrà creata una nuova denominata «{name}».", "Select an identifier type": "Seleziona un tipo di identificatore", - "Select document(s) by clicking on the result table above.": "Seleziona i documenti cliccando sulla tabella dei risultati sopra.", + "Select documents with the checkboxes. Preview any document before associating it.": "Seleziona i documenti con le caselle di controllo. Visualizza l'anteprima di un documento prima di associarlo.", + "Select entrances with the checkboxes to associate them.": "Seleziona gli ingressi con le caselle di controllo per associarli.", "Select the attributes that apply to this entrance": "Seleziona gli attributi applicabili a questo ingresso", "Selected": "Selezionato", "Semi-major radius: ": "Semiasse maggiore: ", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Impossibile visualizzare questo PDF.", "Unable to load recent changes": "Impossibile caricare le modifiche recenti", "Unable to open the statistics dashboard. Please try again.": "Impossibile aprire la dashboard delle statistiche. Riprova.", + "Unable to search for a replacement. Please try again.": "Impossibile cercare un'entità sostitutiva. Riprova.", "Unarchive": "Disarchivia", "Underground time": "Tempo sottoterra", "Underground time (short)": "Sottoterra", diff --git a/packages/web-app/public/lang/ja.json b/packages/web-app/public/lang/ja.json index 063cf406b..ff88a3444 100644 --- a/packages/web-app/public/lang/ja.json +++ b/packages/web-app/public/lang/ja.json @@ -331,6 +331,7 @@ "An article from a source": "出典からの記事", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "{email} にメールが送信されました。メール内のリンクをクリックしてアカウントを有効化してください。", "An email will be sent to:": "メールが送信されます:", + "An entity cannot redirect to itself.": "エンティティをそれ自身にリダイレクトすることはできません。", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "マシフの作成中にエラーが発生しました。", "An error occurred when creating a network.": "ネットワークの作成中にエラーが発生しました。", @@ -419,7 +420,9 @@ "Assign an existing document": "既存の文書を割り当て", "Associate": "関連付け", "Associate {nb} documents": "{nb}個の文書を関連付け", + "Associate {nb} entrances": "入口を{nb}件関連付ける", "Associate 1 document": "1つの文書を関連付け", + "Associate 1 entrance": "入口を1件関連付ける", "Associate to an entrance": "入口に関連付ける", "at": "に", "At least one polygon must not be marked as a hole.": "少なくとも1つのポリゴンは穴としてマークしないでください。", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "このドキュメントにはファイルが添付されていません。", "No interest": "見どころなし", "No issues in this collection.": "このコレクションにはナンバーがありません。", + "No linked entities.": "関連付けられたエンティティはありません。", "No massif subscriptions": "山塊のサブスクリプションなし", "No modifications allowed": "改変禁止", "no name": "名前なし", @@ -2353,7 +2357,8 @@ "Select an authorization document": "認証文書を選択", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "リストから既存の組織を選択してください。選択しない場合、「{name}」という名前の新しい組織が作成されます。", "Select an identifier type": "識別子タイプを選択", - "Select document(s) by clicking on the result table above.": "上の結果テーブルをクリックして文書を選択してください。", + "Select documents with the checkboxes. Preview any document before associating it.": "チェックボックスで文書を選択してください。関連付ける前に文書をプレビューできます。", + "Select entrances with the checkboxes to associate them.": "関連付ける入口をチェックボックスで選択してください。", "Select the attributes that apply to this entrance": "この入口に該当する特性を選択してください", "Selected": "選択済み", "Semi-major radius: ": "半主要半径:", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "この PDF を表示できません。", "Unable to load recent changes": "最近の変更を読み込めませんでした", "Unable to open the statistics dashboard. Please try again.": "統計ダッシュボードを開けませんでした。もう一度お試しください。", + "Unable to search for a replacement. Please try again.": "置き換え先のエンティティを検索できませんでした。もう一度お試しください。", "Unarchive": "アーカイブ解除", "Underground time": "地下時間", "Underground time (short)": "地下", diff --git a/packages/web-app/public/lang/nl.json b/packages/web-app/public/lang/nl.json index 6011bb7f0..b8808dace 100644 --- a/packages/web-app/public/lang/nl.json +++ b/packages/web-app/public/lang/nl.json @@ -331,6 +331,7 @@ "An article from a source": "Een artikel uit een bron", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "Er is een e-mail verzonden naar {email} om uw account te verifiëren. Klik op de link in de e-mail om uw account te activeren.", "An email will be sent to:": "Er wordt een e-mail gestuurd naar:", + "An entity cannot redirect to itself.": "Een entiteit kan niet naar zichzelf doorverwijzen.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "Er is een fout opgetreden bij het aanmaken van een massief.", "An error occurred when creating a network.": "Er is een fout opgetreden bij het aanmaken van een netwerk.", @@ -419,7 +420,9 @@ "Assign an existing document": "Een bestaand document toewijzen", "Associate": "Koppelen", "Associate {nb} documents": "{nb} documenten koppelen", + "Associate {nb} entrances": "{nb} ingangen koppelen", "Associate 1 document": "1 document koppelen", + "Associate 1 entrance": "1 ingang koppelen", "Associate to an entrance": "Koppelen aan een ingang", "at": "om", "At least one polygon must not be marked as a hole.": "Minstens één polygoon mag niet als een gat worden gemarkeerd.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "Er zijn geen bestanden gekoppeld aan dit document.", "No interest": "Niet interessant", "No issues in this collection.": "Geen nummers in deze collectie.", + "No linked entities.": "Geen gekoppelde entiteiten.", "No massif subscriptions": "Geen massief abonnementen", "No modifications allowed": "Geen bewerkingen toegestaan", "no name": "geen naam", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Selecteer een autorisatiedocument", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Selecteer een bestaande organisatie uit de lijst, anders wordt een nieuwe met de naam '{name}' aangemaakt.", "Select an identifier type": "Selecteer een identificatortype", - "Select document(s) by clicking on the result table above.": "Selecteer document(en) door op de resultaattabel hierboven te klikken.", + "Select documents with the checkboxes. Preview any document before associating it.": "Selecteer documenten met de selectievakjes. Bekijk een document voordat u het koppelt.", + "Select entrances with the checkboxes to associate them.": "Selecteer de ingangen met de selectievakjes om ze te koppelen.", "Select the attributes that apply to this entrance": "Selecteer de kenmerken die op deze ingang van toepassing zijn", "Selected": "Geselecteerd", "Semi-major radius: ": "Halve grote as", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Kan deze PDF niet weergeven.", "Unable to load recent changes": "Recente wijzigingen konden niet worden geladen", "Unable to open the statistics dashboard. Please try again.": "Kan het statistiekdashboard niet openen. Probeer het opnieuw.", + "Unable to search for a replacement. Please try again.": "Zoeken naar een vervangende entiteit is mislukt. Probeer het opnieuw.", "Unarchive": "Dearchiveren", "Underground time": "Ondergrondse tijd", "Underground time (short)": "Ondergronds", diff --git a/packages/web-app/public/lang/pt.json b/packages/web-app/public/lang/pt.json index 1b6401ed6..48ccc88b2 100644 --- a/packages/web-app/public/lang/pt.json +++ b/packages/web-app/public/lang/pt.json @@ -331,6 +331,7 @@ "An article from a source": "Um artigo de uma fonte", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "Um email foi enviado para {email} para verificar sua conta. Clique no link no email para ativar sua conta.", "An email will be sent to:": "Será enviado um e-mail para:", + "An entity cannot redirect to itself.": "Uma entidade não pode redirecionar para si mesma.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "Já existe uma entrada com um nome semelhante. Verifique se se trata da mesma cavidade antes de criar uma nova.", "An error occurred when creating a massif.": "Ocorreu um erro ao criar um maciço.", "An error occurred when creating a network.": "Ocorreu um erro ao criar uma rede.", @@ -419,7 +420,9 @@ "Assign an existing document": "Atribuir um documento existente", "Associate": "Associar", "Associate {nb} documents": "Associar {nb} documentos", + "Associate {nb} entrances": "Associar {nb} entradas", "Associate 1 document": "Associar 1 documento", + "Associate 1 entrance": "Associar 1 entrada", "Associate to an entrance": "Associar a uma entrada", "at": "às", "At least one polygon must not be marked as a hole.": "Pelo menos um polígono não deve ser marcado como buraco.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "Nenhum arquivo anexado a este documento.", "No interest": "Sem interesse", "No issues in this collection.": "Nenhum número nesta coleção.", + "No linked entities.": "Não existem entidades associadas.", "No massif subscriptions": "Sem subscrições de maciço", "No modifications allowed": "Nenhuma modificação permitida", "no name": "sem nome", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Selecione um documento de autorização", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Selecione uma organização existente na lista, caso contrário, será criada uma nova com o nome «{name}».", "Select an identifier type": "Selecione um tipo de identificador", - "Select document(s) by clicking on the result table above.": "Selecione o(s) documento(s) clicando na tabela de resultados acima.", + "Select documents with the checkboxes. Preview any document before associating it.": "Selecione os documentos com as caixas de seleção. Pré-visualize um documento antes de o associar.", + "Select entrances with the checkboxes to associate them.": "Selecione as entradas com as caixas de seleção para as associar.", "Select the attributes that apply to this entrance": "Selecione os atributos que se aplicam a esta entrada", "Selected": "Selecionado", "Semi-major radius: ": "Semieixo maior: ", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Não é possível apresentar este PDF.", "Unable to load recent changes": "Não foi possível carregar as alterações recentes", "Unable to open the statistics dashboard. Please try again.": "Não foi possível abrir o painel de estatísticas. Tente novamente.", + "Unable to search for a replacement. Please try again.": "Não foi possível pesquisar uma entidade de substituição. Tente novamente.", "Unarchive": "Desarquivar", "Underground time": "Tempo subterrâneo", "Underground time (short)": "Subterrâneo", diff --git a/packages/web-app/public/lang/ro.json b/packages/web-app/public/lang/ro.json index 493a6a10f..0478fe536 100644 --- a/packages/web-app/public/lang/ro.json +++ b/packages/web-app/public/lang/ro.json @@ -331,6 +331,7 @@ "An article from a source": "Un articol dintr-o sursă", "An email has been sent to {email} to verify your account. Please click the link in the email to activate your account.": "Un email a fost trimis la {email} pentru a verifica contul tău. Fă clic pe linkul din email pentru a-ți activa contul.", "An email will be sent to:": "Un e-mail va fi trimis la:", + "An entity cannot redirect to itself.": "O entitate nu poate redirecționa către ea însăși.", "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.": "An entrance with a similar name already exists. Please check whether it is the same cave before creating a new one.", "An error occurred when creating a massif.": "A apărut o eroare la crearea unui masiv.", "An error occurred when creating a network.": "A apărut o eroare la crearea unei rețele.", @@ -419,7 +420,9 @@ "Assign an existing document": "Atribuiți un document existent", "Associate": "Asociați", "Associate {nb} documents": "Asociați {nb} documente", + "Associate {nb} entrances": "Asociază {nb} intrări", "Associate 1 document": "Asociați 1 document", + "Associate 1 entrance": "Asociază 1 intrare", "Associate to an entrance": "Asociază cu o intrare", "at": "la", "At least one polygon must not be marked as a hole.": "Cel puțin un poligon nu trebuie marcat ca un gol.", @@ -1909,6 +1912,7 @@ "No files attached to this document.": "Nu există fișiere atașate acestui document.", "No interest": "Fără interes", "No issues in this collection.": "Nicio ediție în această colecție.", + "No linked entities.": "Nu există entități asociate.", "No massif subscriptions": "Fără abonamente la masiv", "No modifications allowed": "Nu sunt permise modificări", "no name": "fără nume", @@ -2353,7 +2357,8 @@ "Select an authorization document": "Selectați un document de autorizare", "Select an existing organization from the list, or a new one named \"{name}\" will be created.": "Selectați o organizație existentă din listă, altfel va fi creată una nouă numită „{name}”.", "Select an identifier type": "Selectați un tip de identificator", - "Select document(s) by clicking on the result table above.": "Selectați document(e) făcând clic pe tabelul de rezultate de mai sus.", + "Select documents with the checkboxes. Preview any document before associating it.": "Selectați documentele folosind casetele de selectare. Previzualizați un document înainte de a-l asocia.", + "Select entrances with the checkboxes to associate them.": "Selectați intrările cu ajutorul casetelor de selectare pentru a le asocia.", "Select the attributes that apply to this entrance": "Selectați atributele care se aplică acestei intrări", "Selected": "Selectat", "Semi-major radius: ": "Rază semimajoră", @@ -2684,6 +2689,7 @@ "Unable to display this PDF.": "Acest PDF nu poate fi afișat.", "Unable to load recent changes": "Modificările recente nu au putut fi încărcate", "Unable to open the statistics dashboard. Please try again.": "Nu s-a putut deschide panoul de statistici. Încercați din nou.", + "Unable to search for a replacement. Please try again.": "Nu s-a putut căuta o entitate înlocuitoare. Încercați din nou.", "Unarchive": "Dezarhivează", "Underground time": "Timp subteran", "Underground time (short)": "Subteran", diff --git a/packages/web-app/src/components/appli/AdvancedSearch/SearchResults.jsx b/packages/web-app/src/components/appli/AdvancedSearch/SearchResults.jsx index c98280487..c467b0862 100644 --- a/packages/web-app/src/components/appli/AdvancedSearch/SearchResults.jsx +++ b/packages/web-app/src/components/appli/AdvancedSearch/SearchResults.jsx @@ -3,7 +3,13 @@ import { useAdvancedSearch, refineAdvancedSearch } from '../../../hooks'; import { downloadAdvancedSearchResults } from '../../../actions/Advancedsearch'; import EntityTable from '../../common/EntityTable'; -const SearchResults = ({ onSelected, hideExport, entityType, compact }) => { +const SearchResults = ({ + onSelected, + onRowClick, + hideExport, + entityType, + compact +}) => { const { data, isFetching, isNewQuery, params } = useAdvancedSearch(); const results = data?.results; const totalResults = data?.totalResults ?? 0; @@ -43,6 +49,7 @@ const SearchResults = ({ onSelected, hideExport, entityType, compact }) => { } } onSelected={!onSelected ? null : ids => onSelected(ids, results)} + onRowClick={onRowClick} compact={compact} /> ); @@ -50,6 +57,7 @@ const SearchResults = ({ onSelected, hideExport, entityType, compact }) => { SearchResults.propTypes = { onSelected: PropTypes.func, + onRowClick: PropTypes.func, hideExport: PropTypes.bool, entityType: PropTypes.string, compact: PropTypes.bool diff --git a/packages/web-app/src/components/appli/Descriptions/index.jsx b/packages/web-app/src/components/appli/Descriptions/index.jsx index aa1dedff4..a2cac5ee2 100644 --- a/packages/web-app/src/components/appli/Descriptions/index.jsx +++ b/packages/web-app/src/components/appli/Descriptions/index.jsx @@ -1,7 +1,13 @@ import { useState } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, useIntl } from 'react-intl'; -import { Divider, Link as MuiLink, List, Typography } from '@mui/material'; +import { + Divider, + Link as MuiLink, + List, + Paper, + Typography +} from '@mui/material'; import SectionCreateButton from '@/components/common/SectionCreateButton'; import NetworkInlineLink from '../../common/NetworkInlineLink'; @@ -72,12 +78,17 @@ const Descriptions = ({ content={ <> {hasNetworkDescriptions && ( - <> - - + + - + )} {isFormVisible && ( diff --git a/packages/web-app/src/components/appli/Descriptions/index.test.jsx b/packages/web-app/src/components/appli/Descriptions/index.test.jsx new file mode 100644 index 000000000..f875b9404 --- /dev/null +++ b/packages/web-app/src/components/appli/Descriptions/index.test.jsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import { MemoryRouter } from 'react-router-dom'; +import Descriptions from './index'; + +vi.mock('../../../hooks', () => ({ + useAnchorScroll: () => {}, + usePermissions: () => ({ isAuth: false }), + useCreateDescription: () => ({ mutate: vi.fn() }), + useMoveDescriptionRelevance: () => ({ mutateAsync: vi.fn() }) +})); + +vi.mock('../../../hooks/useMoveRelevanceWithUndo', () => ({ + useMoveRelevanceWithUndo: () => ({ movingId: null, handleMove: vi.fn() }) +})); + +const messages = { + Description: 'Description', + 'Copy link': 'Copy link', + 'network.descriptions.callout': + 'This entrance is part of {networkLink} which also has {descriptionsLink}.', + 'network.descriptions.count': + '{count, plural, one {one description} other {# descriptions}}', + 'descriptions.none.entrance': 'No descriptions for this entrance.' +}; + +const renderDescriptions = networkDescriptionsCount => + render( + + + + + + ); + +it('shows network descriptions in a guideline-style callout with both links', () => { + renderDescriptions(4); + + const networkLink = screen.getByRole('link', { name: 'Example network' }); + const callout = networkLink.closest('.MuiPaper-root'); + expect(callout).toBeInTheDocument(); + expect(getComputedStyle(callout).borderLeftWidth).toBe('3px'); + expect(networkLink).toHaveAttribute('href', '/ui/caves/34'); + expect(screen.getByRole('link', { name: '4 descriptions' })).toHaveAttribute( + 'href', + '/ui/caves/34#description' + ); + expect(screen.getByText('No descriptions for this entrance.')).toBeVisible(); +}); + +it('omits the callout when the network has no descriptions', () => { + renderDescriptions(0); + + expect(screen.queryByRole('link', { name: 'Example network' })).toBeNull(); + expect( + screen.getByText('No descriptions for this entrance.') + ).toBeInTheDocument(); +}); diff --git a/packages/web-app/src/components/appli/Entry/ActionButtons.jsx b/packages/web-app/src/components/appli/Entry/ActionButtons.jsx index 2abe1e4b2..432d9d647 100644 --- a/packages/web-app/src/components/appli/Entry/ActionButtons.jsx +++ b/packages/web-app/src/components/appli/Entry/ActionButtons.jsx @@ -4,7 +4,7 @@ import { useIntl } from 'react-intl'; import HistoryIcon from '@mui/icons-material/History'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/DeleteRounded'; -import DeleteForeverIcon from '@mui/icons-material/RemoveCircleRounded'; +import DeleteForeverIcon from '@mui/icons-material/DeleteForeverRounded'; import RestoreIcon from '@mui/icons-material/RestoreFromTrashRounded'; import ArrowUpward from '@mui/icons-material/ArrowUpward'; import ArrowDownward from '@mui/icons-material/ArrowDownward'; @@ -60,6 +60,10 @@ const ActionButtons = ({ icon: , label: formatMessage({ id: 'Restore' }), onClick: onRestorePress, + // Secondary so restore reads as the discreet counterpart to the + // red "Permanently delete" sitting next to it on a deleted item, + // matching the hierarchy used by the entity-level DeletedCard. + color: 'secondary', hidden: !(!isUpdating && canDelete && isDeleted) }, { diff --git a/packages/web-app/src/components/appli/Entry/Documents/index.jsx b/packages/web-app/src/components/appli/Entry/Documents/index.jsx index c4f3716aa..f35e34083 100644 --- a/packages/web-app/src/components/appli/Entry/Documents/index.jsx +++ b/packages/web-app/src/components/appli/Entry/Documents/index.jsx @@ -30,6 +30,8 @@ const Documents = ({ documents, entranceId, isEditAllowed }) => { const [isDocumentSearchVisible, setIsDocumentSearchVisible] = useState(false); const linkMutation = useLinkDocumentsToEntrance(); const unlinkMutation = useUnlinkDocumentToEntrance(); + const canAssociateDocuments = permissions.isAuth && isEditAllowed; + const isDocumentSearchOpen = canAssociateDocuments && isDocumentSearchVisible; const onSubmitForm = async newDocuments => { await linkMutation.mutateAsync({ entranceId, documents: newDocuments }); @@ -42,11 +44,11 @@ const Documents = ({ documents, entranceId, isEditAllowed }) => { anchorId="documents" title={formatMessage({ id: 'Documents' })} icon={ - permissions.isAuth && - isEditAllowed && ( + canAssociateDocuments ? ( setIsDocumentSearchVisible(!isDocumentSearchVisible) } @@ -73,11 +75,11 @@ const Documents = ({ documents, entranceId, isEditAllowed }) => { } /> - ) + ) : undefined } content={ <> - {isDocumentSearchVisible && ( + {isDocumentSearchOpen && ( <> ({ isAuth: true })); +vi.mock('@/hooks', () => ({ + usePermissions: () => permissions, + useOnlineStatus: () => true, + useIsDesktopLayout: () => true, + useAuthNavigate: () => vi.fn(), + useLinkDocumentsToEntrance: () => ({ mutateAsync: vi.fn() }), + useUnlinkDocumentToEntrance: () => ({ mutate: vi.fn() }) +})); +vi.mock('@/components/common/Layouts/Fixed/ScrollableContent', () => ({ + default: ({ icon, content }) => ( +
+ {icon !== undefined &&
{icon}
} + {content} +
+ ) +})); +vi.mock('@/components/appli/SearchDocumentForm', () => ({ + default: () =>
+})); +vi.mock('@/components/common/DocumentsList/DocumentsList', () => { + const DocumentsList = ({ documents }) =>
{documents.length}
; + DocumentsList.propTypes = { + documents: PropTypes.arrayOf(PropTypes.shape({})) + }; + return { default: DocumentsList }; +}); +vi.mock('@/pages/EntityCreation/entityConfig', () => ({ + EntityIcon: () => null +})); + +const renderDocuments = isEditAllowed => ( + {}}> + + +); + +describe('entrance document association permissions', () => { + beforeEach(() => { + permissions.isAuth = true; + }); + + it.each(['deleted entrance', 'logged out'])( + 'removes the open search and the action slot for a %s', + reason => { + const { rerender } = render(renderDocuments(true)); + fireEvent.click(screen.getByTestId('associate-documents-button')); + expect(screen.getByTestId('association-form')).toBeInTheDocument(); + + if (reason === 'logged out') permissions.isAuth = false; + rerender(renderDocuments(reason !== 'deleted entrance')); + + expect(screen.queryByTestId('association-form')).not.toBeInTheDocument(); + expect(screen.queryByTestId('action-slot')).not.toBeInTheDocument(); + } + ); +}); diff --git a/packages/web-app/src/components/appli/Entry/SectionTitle.jsx b/packages/web-app/src/components/appli/Entry/SectionTitle.jsx index 5855a171e..65d72b936 100644 --- a/packages/web-app/src/components/appli/Entry/SectionTitle.jsx +++ b/packages/web-app/src/components/appli/Entry/SectionTitle.jsx @@ -52,11 +52,11 @@ const SectionTitle = ({ {heading} diff --git a/packages/web-app/src/components/appli/Entry/SectionTitle.test.jsx b/packages/web-app/src/components/appli/Entry/SectionTitle.test.jsx new file mode 100644 index 000000000..85b238910 --- /dev/null +++ b/packages/web-app/src/components/appli/Entry/SectionTitle.test.jsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import SectionTitle from './SectionTitle'; + +describe('SectionTitle', () => { + it('allows long deleted titles to wrap while keeping the deletion label and anchor', () => { + const title = 'A'.repeat(200); + render( + + + + ); + + const heading = screen.getByText(title).closest('.MuiTypography-root'); + expect(getComputedStyle(heading).display).toBe('inline'); + expect(getComputedStyle(heading).overflowWrap).toBe('anywhere'); + expect(getComputedStyle(heading).whiteSpace).not.toBe('nowrap'); + expect(screen.getByText(/\[deleted\]/)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy link' }) + ).toBeInTheDocument(); + expect(document.getElementById('rigging-1')).toBeInTheDocument(); + }); +}); diff --git a/packages/web-app/src/components/appli/Entry/index.jsx b/packages/web-app/src/components/appli/Entry/index.jsx index c33e15e82..b7d484a63 100644 --- a/packages/web-app/src/components/appli/Entry/index.jsx +++ b/packages/web-app/src/components/appli/Entry/index.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, useMemo } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; import { useParams, useNavigate } from 'react-router-dom'; @@ -18,6 +18,7 @@ import BiotechIcon from '@mui/icons-material/Biotech'; import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined'; import PermMediaOutlinedIcon from '@mui/icons-material/PermMediaOutlined'; import { useReactToPrint } from 'react-to-print'; +import { usePostDeletion } from '@/hooks/usePostDeletion'; import AppLink from '../../common/AppLink'; import PageContainer from '../../common/Layouts/PageContainer'; @@ -91,7 +92,13 @@ export const Entry = ({ useState(false); const [isDeleteConfirmationPermanent, setIsDeleteConfirmationPermanent] = useState(false); - const [wantedDeletedState, setWantedDeletedState] = useState(false); + const { onDeletePress, wantedDeletedState, setWantedDeletedState } = + usePostDeletion({ + entityType: DELETED_ENTITIES.entrance, + id: entranceId, + entity: entrance, + deleteMutation + }); const userId = useUserProperties()?.id ?? null; const { isExplored, isExploredLoading, handleToggleExplored } = useExplored({ entranceId: entrance?.id, @@ -99,10 +106,6 @@ export const Entry = ({ }); const mapPositions = useMemo(() => (entrance ? [entrance] : []), [entrance]); - useEffect(() => { - if (entrance) setWantedDeletedState(entrance.isDeleted); - }, [entrance]); - const isActionLoading = wantedDeletedState !== entrance?.isDeleted; let onDelete = null; @@ -113,12 +116,6 @@ export const Entry = ({ }; } - const onDeletePress = (entityId, isPermanent) => { - setWantedDeletedState(true); - deleteMutation.mutate({ id: entranceId, entityId, isPermanent }); - if (isPermanent) navigate('/', { replace: true }); - }; - const onRestorePress = () => { setWantedDeletedState(false); restoreMutation.mutate({ id: entranceId }); @@ -352,6 +349,7 @@ export const Entry = ({ )} { - if (massif) setWantedDeletedState(massif.isDeleted); - }, [massif]); - const { data: dataMassif } = useStatisticsMassif(massifIdInt); useScrollToHashOnLoad(dataMassif); @@ -96,11 +99,6 @@ const Massif = ({ } } - const onDeletePress = (entityId, isPermanent) => { - setWantedDeletedState(true); - deleteMutation.mutate({ id: massifId, entityId, isPermanent }); - if (isPermanent) navigate('/', { replace: true }); - }; const onRestorePress = () => { setWantedDeletedState(false); restoreMutation.mutate({ id: massifId }); @@ -242,6 +240,7 @@ const Massif = ({ )} { const { formatMessage } = useIntl(); - const navigate = useNavigate(); const { caveId } = useParams(); const { isAuth, isAdmin, isModerator } = usePermissions(); const deleteMutation = useDeleteCave(); @@ -76,14 +76,16 @@ export const Network = ({ useState(false); const [isDeleteConfirmationPermanent, setIsDeleteConfirmationPermanent] = useState(false); - const [wantedDeletedState, setWantedDeletedState] = useState(false); + const { onDeletePress, wantedDeletedState, setWantedDeletedState } = + usePostDeletion({ + entityType: DELETED_ENTITIES.network, + id: caveId, + entity: cave, + deleteMutation + }); const handleShare = useSharePage(); const handlePrint = useReactToPrint({ contentRef: componentRef }); - useEffect(() => { - if (cave) setWantedDeletedState(cave.isDeleted); - }, [cave]); - const isActionLoading = wantedDeletedState !== cave?.isDeleted; const country = cave?.entrances?.[0]?.country; @@ -95,11 +97,6 @@ export const Network = ({ }; } - const onDeletePress = (entityId, isPermanent) => { - setWantedDeletedState(true); - deleteMutation.mutate({ id: caveId, entityId, isPermanent }); - if (isPermanent) navigate('/', { replace: true }); - }; const onRestorePress = () => { setWantedDeletedState(false); restoreMutation.mutate({ id: caveId }); @@ -263,6 +260,7 @@ export const Network = ({ )} { useState(false); const [isDeleteConfirmationPermanent, setIsDeleteConfirmationPermanent] = useState(false); - const [wantedDeletedState, setWantedDeletedState] = useState(false); + const { onDeletePress, wantedDeletedState, setWantedDeletedState } = + usePostDeletion({ + entityType: DELETED_ENTITIES.organization, + id: organizationId, + entity: organization, + deleteMutation: deleteOrganizationMutation + }); const [isJoining, setIsJoining] = useState(false); const [joinLeaveError, setJoinLeaveError] = useState(null); const [isCaveSearchVisible, setIsCaveSearchVisible] = useState(false); @@ -79,10 +86,6 @@ const Organization = ({ error, isPaused = false, isLoading, organization }) => { ); const canManageCaves = isAdmin || isModerator || isMember; - useEffect(() => { - if (organization) setWantedDeletedState(organization.isDeleted); - }, [organization]); - let onEdit = null; let onDelete = null; if (isAuth && !organization?.isDeleted) { @@ -97,15 +100,6 @@ const Organization = ({ error, isPaused = false, isLoading, organization }) => { } } - const onDeletePress = (entityId, isPermanent) => { - setWantedDeletedState(true); - deleteOrganizationMutation.mutate({ - id: organizationId, - entityId, - isPermanent - }); - if (isPermanent) navigate('/', { replace: true }); - }; const onRestorePress = () => { setWantedDeletedState(false); restoreOrganizationMutation.mutate({ id: organizationId }); @@ -295,6 +289,7 @@ const Organization = ({ error, isPaused = false, isLoading, organization }) => { )} setIsDeleteConfirmationOpen(true); } - const onDeletePress = (entityId, isPermanent) => { - deleteMutation.mutate({ id: person?.id, entityId }); - if (isPermanent) navigate('/', { replace: true }); - }; + const { onDeletePress } = usePostDeletion({ + entityType: DELETED_ENTITIES.person, + id: person?.id, + entity: person, + deleteMutation + }); let title = ''; if (person) { @@ -235,11 +238,12 @@ const Person = ({ <> setIsDeleteConfirmationOpen(false)} - onConfirmation={entity => onDeletePress(entity?.id, true)} + onConfirmation={entity => onDeletePress(entity?.id)} /> {isAuthor ? ( diff --git a/packages/web-app/src/components/appli/Person/Person.test.jsx b/packages/web-app/src/components/appli/Person/Person.test.jsx new file mode 100644 index 000000000..99df046c2 --- /dev/null +++ b/packages/web-app/src/components/appli/Person/Person.test.jsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; + +import Person from './Person'; + +const state = vi.hoisted(() => ({ mutate: vi.fn(), navigate: vi.fn() })); + +vi.mock('@tanstack/react-query', async importOriginal => ({ + ...(await importOriginal()), + useQueryClient: () => ({ invalidateQueries: vi.fn() }) +})); + +vi.mock('react-router-dom', async importOriginal => ({ + ...(await importOriginal()), + useNavigate: () => state.navigate +})); + +vi.mock('@/hooks', () => ({ + useDeletePerson: () => ({ mutate: state.mutate }), + useUserProperties: () => ({ id: 9 }), + usePermissions: () => ({ isAdmin: true, isModerator: true }), + useSharePage: () => vi.fn() +})); + +vi.mock('@/components/common/Layouts/PageHeader', () => ({ + default: ({ actions }) =>
{actions}
+})); + +vi.mock('@/components/common/Layouts/ResponsiveActions', () => ({ + default: ({ items }) => ( +
+ {items + .filter(item => !item.hidden) + .map(item => ( + + ))} +
+ ) +})); + +vi.mock('@/components/common/card/Deleted', () => ({ + DELETED_ENTITIES: { person: { url: '/ui/persons/', str: 'Person' } }, + DeleteConfirmationDialog: ({ isOpen, onConfirmation }) => + isOpen ? ( + + ) : null +})); + +vi.mock('./AuthorBody', () => ({ default: () => null })); +vi.mock('./CaverBody', () => ({ default: () => null })); + +describe('Person permanent deletion', () => { + beforeEach(() => { + state.mutate.mockReset(); + state.navigate.mockReset(); + }); + + it('opens the replacement only after the deletion succeeds', () => { + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm deletion' })); + + expect(state.mutate).toHaveBeenCalledWith( + { id: 42, entityId: 43, isPermanent: true }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + expect(state.navigate).not.toHaveBeenCalled(); + + const [, { onSuccess }] = state.mutate.mock.calls[0]; + onSuccess(); + expect(state.navigate).toHaveBeenCalledWith('/ui/persons/43', { + replace: true + }); + }); +}); diff --git a/packages/web-app/src/components/appli/SearchDocumentForm/index.jsx b/packages/web-app/src/components/appli/SearchDocumentForm/index.jsx index 485990952..9ab0be068 100644 --- a/packages/web-app/src/components/appli/SearchDocumentForm/index.jsx +++ b/packages/web-app/src/components/appli/SearchDocumentForm/index.jsx @@ -1,15 +1,18 @@ -import { useState } from 'react'; +import { lazy, Suspense, useState } from 'react'; import { useIntl } from 'react-intl'; import PropTypes from 'prop-types'; -import { Box, Button } from '@mui/material'; +import { Box, Button, CircularProgress, useMediaQuery } from '@mui/material'; import { styled } from '@mui/material/styles'; import OfflineDisabled from '@/components/common/OfflineDisabled'; +import StandardDialog from '@/components/common/StandardDialog'; import { useOnlineStatus, resetAdvancedSearch } from '@/hooks'; import DocumentSearch from '../AdvancedSearch/DocumentSearch'; import SearchResults from '../AdvancedSearch/SearchResults'; import Alert from '../../common/Alert'; +const DocumentDetails = lazy(() => import('@/pages/DocumentDetails')); + const SpacedButton = styled(Button)` ${({ theme }) => ` margin: 0 ${theme.spacing(0.5)};`} @@ -18,8 +21,10 @@ const SpacedButton = styled(Button)` const SearchDocumentForm = ({ closeForm, onSubmit, onSuccess }) => { const { formatMessage } = useIntl(); const isOnline = useOnlineStatus(); + const isNarrowViewport = useMediaQuery(theme => theme.breakpoints.down('sm')); const [selectedDocuments, setSelectedDocuments] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); + const [previewedDocumentId, setPreviewedDocumentId] = useState(null); const resetForm = () => { resetAdvancedSearch(); @@ -56,48 +61,69 @@ const SearchDocumentForm = ({ closeForm, onSubmit, onSuccess }) => { } return ( - - -
- { - const resultIds = results.map(e => e.id); - setSelectedDocuments([ - ...selectedDocuments.filter(e => !resultIds.includes(e.id)), - ...results.filter(e => ids.includes(e.id)) - ]); - }} - /> - {selectedDocuments.length === 0 && ( - + + +
+ { + setPreviewedDocumentId(document.id); + return false; + }} + onSelected={(ids, results) => { + const resultIds = results.map(e => e.id); + setSelectedDocuments([ + ...selectedDocuments.filter(e => !resultIds.includes(e.id)), + ...results.filter(e => ids.includes(e.id)) + ]); + }} /> - )} - - {closeForm && ( - - {formatMessage({ id: 'Cancel' })} - + {selectedDocuments.length === 0 && ( + )} - - {formatMessage({ id: 'Reset' })} - - - - {associateMessage} + + {closeForm && ( + + {formatMessage({ id: 'Cancel' })} + + )} + + {formatMessage({ id: 'Reset' })} - + + + {associateMessage} + + +
-
+ setPreviewedDocumentId(null)} + title={formatMessage({ id: 'Detailed document view' })}> + {previewedDocumentId !== null && ( + }> + + + )} + + ); }; diff --git a/packages/web-app/src/components/appli/SearchDocumentForm/index.test.jsx b/packages/web-app/src/components/appli/SearchDocumentForm/index.test.jsx index 1044342de..8fb3bafb8 100644 --- a/packages/web-app/src/components/appli/SearchDocumentForm/index.test.jsx +++ b/packages/web-app/src/components/appli/SearchDocumentForm/index.test.jsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { IntlProvider } from 'react-intl'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; import { resetAdvancedSearch } from '@/hooks'; import SearchDocumentForm from './index'; @@ -18,12 +19,25 @@ vi.mock('../AdvancedSearch/DocumentSearch', () => ({ })); vi.mock('../AdvancedSearch/SearchResults', () => ({ - default: ({ onSelected }) => ( - + default: ({ onSelected, onRowClick }) => ( + <> + + + + ) +})); + +vi.mock('@/pages/DocumentDetails', () => ({ + default: ({ id, hideActions }) => ( +
+ Document {id}, actions hidden: {String(hideActions)} +
) })); @@ -31,15 +45,20 @@ const messages = { Associate: 'Associate', 'Associate 1 document': 'Associate 1 document', 'Associate {nb} documents': 'Associate {nb} documents', + 'Detailed document view': 'Detailed document view', Reset: 'Reset', - 'Select document(s) by clicking on the result table above.': - 'Select documents' + 'Select documents with the checkboxes. Preview any document before associating it.': + 'Select documents', + close: 'close' }; +const theme = createTheme(); const renderForm = ({ onSubmit, onSuccess }) => render( - + + + ); @@ -84,4 +103,52 @@ describe('SearchDocumentForm submission', () => { screen.getByRole('button', { name: 'Associate 1 document' }) ).toBeEnabled(); }); + + it('previews a document without changing the pending association', async () => { + const onSubmit = vi.fn().mockResolvedValue(); + renderForm({ onSubmit }); + selectDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Preview result' })); + + expect(await screen.findByTestId('document-preview')).toHaveTextContent( + 'Document 1, actions hidden: true' + ); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'close' })); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); + expect( + screen.getByRole('button', { name: 'Associate 1 document' }) + ).toBeEnabled(); + + fireEvent.click( + screen.getByRole('button', { name: 'Associate 1 document' }) + ); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith([{ id: 1, title: 'Document' }]) + ); + }); + + it('allows preview before selecting a document', async () => { + const onSubmit = vi.fn(); + renderForm({ onSubmit }); + + fireEvent.click(screen.getByRole('button', { name: 'Preview result' })); + + expect(await screen.findByTestId('document-preview')).toHaveTextContent( + 'Document 1, actions hidden: true' + ); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'close' })); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); + expect( + screen.getByRole('button', { name: 'Associate 0 documents' }) + ).toBeDisabled(); + }); }); diff --git a/packages/web-app/src/components/appli/SearchEntranceForm/index.jsx b/packages/web-app/src/components/appli/SearchEntranceForm/index.jsx new file mode 100644 index 000000000..e3820d0fc --- /dev/null +++ b/packages/web-app/src/components/appli/SearchEntranceForm/index.jsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import { Box, Button } from '@mui/material'; + +import { ADVANCED_SEARCH_TYPES } from '@/conf/config'; +import { useOnlineStatus, resetAdvancedSearch } from '@/hooks'; +import OfflineDisabled from '@/components/common/OfflineDisabled'; +import Alert from '@/components/common/Alert'; +import EntrancesSearch from '../AdvancedSearch/EntrancesSearch'; +import SearchResults from '../AdvancedSearch/SearchResults'; + +const SearchEntranceForm = ({ onSubmit, onSuccess }) => { + const { formatMessage } = useIntl(); + const isOnline = useOnlineStatus(); + const [selectedEntrances, setSelectedEntrances] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + + const resetForm = () => { + resetAdvancedSearch(); + setSelectedEntrances([]); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + await onSubmit(selectedEntrances); + resetForm(); + onSuccess(); + } catch (_error) { + // Mutation errors are surfaced by the QueryClient's global notifier. + } finally { + setIsSubmitting(false); + } + }; + + const associateMessage = + selectedEntrances.length === 1 + ? formatMessage({ id: 'Associate 1 entrance' }) + : formatMessage( + { id: 'Associate {nb} entrances' }, + { nb: selectedEntrances.length } + ); + + return ( + + + { + const resultIds = results.map(entrance => entrance.id); + setSelectedEntrances(previous => [ + ...previous.filter(entrance => !resultIds.includes(entrance.id)), + ...results.filter(entrance => ids.includes(entrance.id)) + ]); + }} + /> + {selectedEntrances.length === 0 && ( + + )} + + + + + + + + ); +}; + +SearchEntranceForm.propTypes = { + onSubmit: PropTypes.func.isRequired, + onSuccess: PropTypes.func.isRequired +}; + +export default SearchEntranceForm; diff --git a/packages/web-app/src/components/appli/SearchEntranceForm/index.test.jsx b/packages/web-app/src/components/appli/SearchEntranceForm/index.test.jsx new file mode 100644 index 000000000..1200694f5 --- /dev/null +++ b/packages/web-app/src/components/appli/SearchEntranceForm/index.test.jsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; + +import { resetAdvancedSearch } from '@/hooks'; +import SearchEntranceForm from './index'; + +vi.mock('@/hooks', () => ({ + resetAdvancedSearch: vi.fn(), + useOnlineStatus: () => true +})); + +vi.mock('@/components/common/OfflineDisabled', () => ({ + default: ({ children }) => children +})); + +vi.mock('../AdvancedSearch/EntrancesSearch', () => ({ + default: () => null +})); + +vi.mock('../AdvancedSearch/SearchResults', () => ({ + default: ({ onSelected }) => ( + <> + + + + ) +})); + +const messages = { + 'Associate 1 entrance': 'Associate 1 entrance', + 'Associate {nb} entrances': 'Associate {nb} entrances', + 'Select entrances with the checkboxes to associate them.': + 'Select entrances with the checkboxes to associate them.', + Reset: 'Reset' +}; + +const renderForm = (onSubmit, onSuccess = vi.fn()) => + render( + + + + ); + +describe('SearchEntranceForm', () => { + beforeEach(() => vi.clearAllMocks()); + + it('keeps selections across result pages and closes after success', async () => { + const onSubmit = vi.fn().mockResolvedValue(); + const onSuccess = vi.fn(); + renderForm(onSubmit, onSuccess); + fireEvent.click(screen.getByRole('button', { name: 'Select first' })); + fireEvent.click(screen.getByRole('button', { name: 'Select second page' })); + + fireEvent.click( + screen.getByRole('button', { name: 'Associate 2 entrances' }) + ); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + expect(onSubmit).toHaveBeenCalledWith([ + { id: 1, name: 'First entrance' }, + { id: 2, name: 'Second entrance' } + ]); + expect(resetAdvancedSearch).toHaveBeenCalledOnce(); + }); + + it('retains the selection and stays open on failure', async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error('request failed')); + const onSuccess = vi.fn(); + renderForm(onSubmit, onSuccess); + fireEvent.click(screen.getByRole('button', { name: 'Select first' })); + fireEvent.click( + screen.getByRole('button', { name: 'Associate 1 entrance' }) + ); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSuccess).not.toHaveBeenCalled(); + expect(resetAdvancedSearch).not.toHaveBeenCalled(); + expect( + screen.getByRole('button', { name: 'Associate 1 entrance' }) + ).toBeEnabled(); + }); +}); diff --git a/packages/web-app/src/components/common/EntityTable/MobileEntityList.jsx b/packages/web-app/src/components/common/EntityTable/MobileEntityList.jsx index 6bbb28938..577287b9a 100644 --- a/packages/web-app/src/components/common/EntityTable/MobileEntityList.jsx +++ b/packages/web-app/src/components/common/EntityTable/MobileEntityList.jsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; import { Box, Button, @@ -9,13 +10,17 @@ import { Checkbox, CircularProgress, Divider, + IconButton, Stack, + Tooltip, Typography } from '@mui/material'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import SearchOffIcon from '@mui/icons-material/SearchOff'; +import VisibilityIcon from '@mui/icons-material/Visibility'; import AppLink from '../AppLink'; import Translate from '../Translate'; +import { useLongPress } from '../../../hooks/useLongPress'; const MobileEntityCard = React.memo( ({ @@ -28,20 +33,66 @@ const MobileEntityCard = React.memo( onToggle = null, onRowClick = null }) => { + const { formatMessage } = useIntl(); + const longPressFiredRef = useRef(false); const titleCol = columns.find(c => c.isTitle) ?? columns.find(c => c.field === 'name' || c.field === 'title') ?? columns[0]; const bodyColumns = columns.filter(c => c !== titleCol); - // In selection mode (onToggle set) the card toggles selection, not - // navigation, so it can't be a real link. Otherwise it's a genuine - // navigation target: rendered as an AppLink for keyboard/middle-click support. + // Selection mode (onToggle set): a short tap toggles selection. When a + // preview is also available (onRowClick set), the card offers two extra + // ways to reach it — a dedicated icon button and a long-press — mirroring + // desktop's "checkbox = select, row = preview" split. + // Outside selection mode the card is a real navigation link. + const canPreview = Boolean(onToggle && onRowClick); + + const rawLongPress = useLongPress(() => { + if (!canPreview) return; + longPressFiredRef.current = true; + onRowClick(doc); + }); + // Reset the fired flag at the start of every new touch so a long-press + // that doesn't produce a follow-up click event can't block the next tap. + const longPressHandlers = canPreview + ? { + ...rawLongPress, + onTouchStart: e => { + longPressFiredRef.current = false; + rawLongPress.onTouchStart(e); + }, + onTouchCancel: e => { + longPressFiredRef.current = false; + rawLongPress.onTouchCancel(e); + } + } + : null; + const handleClick = () => { + if (longPressFiredRef.current) { + longPressFiredRef.current = false; + return; + } if (onToggle) onToggle(doc.id); else if (onRowClick) onRowClick(doc); }; + const handlePreviewClick = e => { + e.stopPropagation(); + onRowClick(doc); + }; + + // A preview may intercept the compatibility click after a long press. + // Reset for a fresh mouse/pen or keyboard activation, while preserving + // suppression of the click generated by the original touch gesture. + const handlePointerDown = e => { + if (e.pointerType !== 'touch') longPressFiredRef.current = false; + }; + const handleKeyDown = () => { + longPressFiredRef.current = false; + }; + return ( - - - + + - {onToggle && ( - e.stopPropagation()} - onChange={() => onToggle(doc.id)} + + {onToggle && ( + e.stopPropagation()} + onTouchStart={e => e.stopPropagation()} + onTouchMove={e => e.stopPropagation()} + onTouchEnd={e => e.stopPropagation()} + onTouchCancel={e => e.stopPropagation()} + onChange={() => onToggle(doc.id)} + sx={{ + p: 0.25, + flexShrink: 0 + }} + /> + )} + {typeof icon === 'function' ? icon(doc) : icon} + + {renderCellFn(doc, titleCol.field, titleCol.render)} + + + {!onToggle && ( + )} - {typeof icon === 'function' ? icon(doc) : icon} - - {renderCellFn(doc, titleCol.field, titleCol.render)} - - {!onToggle && ( - + {bodyColumns.length > 0 && ( + + display: 'flex', + flexDirection: 'column' + }}> + {bodyColumns.map(col => { + const value = renderCellFn(doc, col.field, col.render); + const isMissing = value === '-'; + return ( + + + {col.label} + + + {value} + + + ); + })} + )} - - - {bodyColumns.length > 0 && ( - + + {canPreview && ( + + - {bodyColumns.map(col => { - const value = renderCellFn(doc, col.field, col.render); - const isMissing = value === '-'; - return ( - - - {col.label} - - - {value} - - - ); - })} - - )} - - + + + + )} + ); } diff --git a/packages/web-app/src/components/common/EntityTable/MobileEntityList.test.jsx b/packages/web-app/src/components/common/EntityTable/MobileEntityList.test.jsx index 6fea84bac..3c2cc6c1b 100644 --- a/packages/web-app/src/components/common/EntityTable/MobileEntityList.test.jsx +++ b/packages/web-app/src/components/common/EntityTable/MobileEntityList.test.jsx @@ -1,9 +1,9 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { IntlProvider } from 'react-intl'; import MobileEntityList from './MobileEntityList'; -const messages = { Name: 'Name' }; +const messages = { Name: 'Name', Preview: 'Preview' }; const columns = [ { field: 'name', label: 'Name', visible: true, isTitle: true } @@ -71,3 +71,68 @@ describe('MobileEntityList - Controlled selection', () => { expect(onSelected).toHaveBeenCalledWith([]); }); }); + +describe('MobileEntityList preview gestures', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + act(() => vi.runOnlyPendingTimers()); + vi.useRealTimers(); + }); + + const touch = { touches: [{ clientX: 10, clientY: 10 }] }; + + it('keeps a checkbox long press separate from previewing', () => { + const onSelected = vi.fn(); + const onRowClick = vi.fn(); + renderList({ onSelected, onRowClick, selectedIds: [] }); + const checkbox = screen.getByRole('checkbox'); + + fireEvent.touchStart(checkbox, touch); + act(() => vi.advanceTimersByTime(600)); + fireEvent.touchEnd(checkbox); + fireEvent.click(checkbox); + + expect(onRowClick).not.toHaveBeenCalled(); + expect(onSelected).toHaveBeenCalledExactlyOnceWith([1]); + }); + + it('suppresses selection on the click following a card long press', () => { + const onSelected = vi.fn(); + const onRowClick = vi.fn(); + renderList({ onSelected, onRowClick, selectedIds: [] }); + const card = screen.getByText('Cave A'); + + fireEvent.touchStart(card, touch); + act(() => vi.advanceTimersByTime(600)); + fireEvent.touchEnd(card); + fireEvent.click(card); + + expect(onRowClick).toHaveBeenCalledExactlyOnceWith(defaultProps.rows[0]); + expect(onSelected).not.toHaveBeenCalled(); + }); + + it.each(['keyboard', 'mouse'])( + 'allows the first %s activation when a preview intercepts the touch click', + input => { + const onSelected = vi.fn(); + const onRowClick = vi.fn(); + renderList({ onSelected, onRowClick, selectedIds: [] }); + const card = screen.getByText('Cave A').closest('button'); + + fireEvent.touchStart(card, touch); + act(() => vi.advanceTimersByTime(600)); + fireEvent.touchEnd(card); + // No click reaches the card: the newly opened dialog intercepted it. + if (input === 'keyboard') { + fireEvent.keyDown(card, { key: 'Enter', code: 'Enter' }); + // Native buttons synthesize a click on Enter in the browser. + fireEvent.click(card); + } else { + fireEvent.pointerDown(card, { pointerType: 'mouse' }); + fireEvent.click(card); + } + + expect(onSelected).toHaveBeenCalledExactlyOnceWith([1]); + } + ); +}); diff --git a/packages/web-app/src/components/common/StandardDialog/index.jsx b/packages/web-app/src/components/common/StandardDialog/index.jsx index 8c54f0fe9..bb6f79a32 100644 --- a/packages/web-app/src/components/common/StandardDialog/index.jsx +++ b/packages/web-app/src/components/common/StandardDialog/index.jsx @@ -24,7 +24,7 @@ const DialogContent = styled(MuiDialogContent, { && { overflow: ${({ $scrollable }) => ($scrollable ? 'auto' : 'visible')}; @media (max-width: 600px) { - padding: 8px 16px; + padding: ${({ $dense }) => ($dense ? '8px 4px' : '8px 16px')}; ${({ $centerMobile }) => $centerMobile ? 'display: flex; flex-direction: column; justify-content: center;' @@ -51,6 +51,7 @@ const StandardDialog = ({ fullWidth = false, scrollable = false, centerContentMobile = false, + dense = false, maxWidth = 'sm', open = false, onClose = () => {}, @@ -79,7 +80,8 @@ const StandardDialog = ({ {children && ( + $centerMobile={centerContentMobile} + $dense={dense}> {children} )} @@ -97,6 +99,7 @@ StandardDialog.propTypes = { ]), centerContentMobile: PropTypes.bool, children: PropTypes.node, + dense: PropTypes.bool, fullScreen: PropTypes.bool, fullWidth: PropTypes.bool, maxWidth: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']), diff --git a/packages/web-app/src/components/common/card/Deleted.jsx b/packages/web-app/src/components/common/card/Deleted.jsx index dd8ddc98c..58aaeb948 100644 --- a/packages/web-app/src/components/common/card/Deleted.jsx +++ b/packages/web-app/src/components/common/card/Deleted.jsx @@ -8,6 +8,7 @@ import { Card, Stack, IconButton, + FormHelperText, Typography, CircularProgress } from '@mui/material'; @@ -15,6 +16,7 @@ import RestoreIcon from '@mui/icons-material/RestoreFromTrashRounded'; import DeleteForeverIcon from '@mui/icons-material/DeleteForeverRounded'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import { getDeletedEntityRedirectUrl } from '@/utils/deletedEntityRedirect'; import StandardDialog from '../StandardDialog'; import Alert from '../Alert'; @@ -65,6 +67,11 @@ export const DELETED_ENTITIES = { } }; +const isCurrentEntity = (candidate, entityId) => + candidate?.id != null && + entityId != null && + String(candidate.id) === String(entityId); + const StyledEntityIcon = styled(EntityIcon)` float: left; `; @@ -88,9 +95,10 @@ export const DeletedCard = ({ }) => { const { formatMessage } = useIntl(); const entityI18n = formatMessage({ id: entityType.str }); - const redirectToUrl = entity.redirectTo - ? entityType.url + entity.redirectTo - : null; + const redirectToUrl = getDeletedEntityRedirectUrl( + entityType, + entity.redirectTo + ); const hasActions = !!redirectToUrl || @@ -194,6 +202,7 @@ export const DeletedCard = ({ export const DeleteConfirmationDialog = ({ entityType, + entityId, isOpen, isLoading, isPermanent, @@ -219,13 +228,16 @@ export const DeleteConfirmationDialog = ({ // character earlier than the pre-migration behavior of this dialog. minChars: 3 }); - const suggestions = data?.results ?? []; + const suggestions = (data?.results ?? []).filter( + suggestion => !isCurrentEntity(suggestion, entityId) + ); useEffect(() => { if (!isOpen) setSelectedEntity(null); }, [isOpen, setSelectedEntity]); const handleSelection = selection => { + if (isCurrentEntity(selection, entityId)) return; if (selection) { setSelectedEntity(nomelizeSearchEntity(selection)); } @@ -327,21 +339,35 @@ export const DeleteConfirmationDialog = ({ <> {searchTitle} {!selectedEntity && ( - + + + {formatMessage({ + id: 'An entity cannot redirect to itself.' + })} + + {!!error && ( + + {formatMessage({ + id: 'Unable to search for a replacement. Please try again.' + })} + )} - inputValue={inputValue} - suggestions={suggestions} - /> + )} {selectedEntity && ( @@ -417,6 +443,7 @@ Deleted.propTypes = { DeleteConfirmationDialog.propTypes = { entityType: DeletedCard.propTypes.entityType.isRequired, + entityId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), isOpen: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, isPermanent: PropTypes.bool.isRequired, diff --git a/packages/web-app/src/components/common/card/Deleted.test.jsx b/packages/web-app/src/components/common/card/Deleted.test.jsx new file mode 100644 index 000000000..28f697377 --- /dev/null +++ b/packages/web-app/src/components/common/card/Deleted.test.jsx @@ -0,0 +1,136 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; + +import { DeleteConfirmationDialog, DELETED_ENTITIES } from './Deleted'; + +const searchState = vi.hoisted(() => ({ results: [], error: null })); + +vi.mock('@/hooks', () => ({ + useDebounce: value => value, + useQuickSearch: () => ({ + data: { results: searchState.results }, + error: searchState.error, + isFetching: false + }) +})); + +vi.mock('../StandardDialog', () => ({ + default: ({ open, children, actions }) => + open ? ( +
+ {children} + {actions} +
+ ) : null +})); + +const messages = { + Massif: 'Massif', + 'Deletion confirmation': 'Deletion confirmation', + 'Merge and permanently delete': 'Merge and permanently delete', + Cancel: 'Cancel', + Delete: 'Delete', + disabled: 'disabled', + 'Search for a {entityFmt}': 'Search for a {entityFmt}', + 'delete-permanent-confirmation-dialog': 'Delete {entityFmt}?', + 'delete-permanent-merge-mandatory': 'Merge into another {entityFmt}', + remove: 'remove', + 'An entity cannot redirect to itself.': + 'An entity cannot redirect to itself.', + 'Unable to search for a replacement. Please try again.': + 'Unable to search for a replacement. Please try again.', + 'No result (enter at least {count} characters)': + 'No result (enter at least {count} characters)' +}; + +const renderDialog = (entityId = 42, onConfirmation = vi.fn()) => ( + + {}} + onConfirmation={onConfirmation} + /> + +); + +describe('DeleteConfirmationDialog replacement selection', () => { + beforeEach(() => { + searchState.error = null; + searchState.results = [ + { id: '42', name: 'Current massif', _type: 'massifs' }, + { id: 43, name: 'Other massif', _type: 'massifs' } + ]; + }); + + it.each([42, '42'])( + 'excludes the current entity (%s) and lets users select another result', + async entityId => { + const onConfirmation = vi.fn(); + render(renderDialog(entityId, onConfirmation)); + const confirmButton = screen.getByRole('button', { + name: 'Merge and permanently delete' + }); + expect(confirmButton).toBeDisabled(); + + fireEvent.focus(screen.getByRole('combobox')); + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'massif' } + }); + const otherResult = await screen.findByRole('option', { + name: /Other massif/ + }); + expect( + screen.queryByRole('option', { name: /Current massif/ }) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + fireEvent.click(otherResult); + expect(confirmButton).toBeEnabled(); + fireEvent.click(confirmButton); + + expect(onConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ id: 43, title: 'Other massif' }) + ); + } + ); + + it('shows a separate search error and clears it after recovery', () => { + const { rerender } = render(renderDialog()); + const constraintHint = screen.getByText( + 'An entity cannot redirect to itself.' + ); + expect(constraintHint).not.toHaveAttribute('aria-live'); + expect(constraintHint).not.toHaveClass('Mui-error'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + fireEvent.focus(screen.getByRole('combobox')); + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'massif' } + }); + searchState.error = new Error('search failed'); + searchState.results = []; + rerender(renderDialog()); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Unable to search for a replacement. Please try again.' + ); + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-invalid', + 'true' + ); + expect(constraintHint).not.toHaveClass('Mui-error'); + + searchState.error = null; + rerender(renderDialog()); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-invalid', + 'false' + ); + }); +}); diff --git a/packages/web-app/src/components/common/entitiesList/LinkedEntityCards.jsx b/packages/web-app/src/components/common/entitiesList/LinkedEntityCards.jsx index a6b2daf3e..5bb4b52f1 100644 --- a/packages/web-app/src/components/common/entitiesList/LinkedEntityCards.jsx +++ b/packages/web-app/src/components/common/entitiesList/LinkedEntityCards.jsx @@ -55,24 +55,29 @@ const LinkedEntityCards = ({ const unlinkLabel = `${formatMessage({ id: 'unlink' })} ${ entity.label }`; - const itemActionButton = onUnlink ? ( - - - - setEntityToUnlink(entity)} - sx={{ touchAction: 'manipulation' }}> - - - - - - ) : null; + // Entities default to unlinkable so existing callers (massifs, caves) + // keep their behaviour; opt out per entity when the entity's own + // endpoint doesn't support it or isn't wired up yet. + const canUnlinkEntity = entity.canUnlink !== false; + const itemActionButton = + onUnlink && canUnlinkEntity ? ( + + + + setEntityToUnlink(entity)} + sx={{ touchAction: 'manipulation' }}> + + + + + + ) : null; return ( { expect(onUnlink).toHaveBeenCalledWith(entities[0]); }); +it('hides the unlink button on entities that opt out', async () => { + const onUnlink = vi.fn(); + renderCards({ + linkedEntities: [ + { + id: 7, + type: 'massif', + iconType: 'massif', + label: 'Vercors', + secondary: 'Massif', + url: '/ui/massifs/7', + canUnlink: false + }, + { + id: 11, + type: 'cave', + iconType: 'network', + label: 'Reseau', + secondary: 'Cave', + url: '/ui/caves/11', + canUnlink: false + }, + { + id: 42, + type: 'entrance', + iconType: 'entrance', + label: 'Grotte exemple', + secondary: 'Entrance', + url: '/ui/entrances/42', + canUnlink: true + } + ], + onUnlink + }); + + expect(screen.queryByRole('button', { name: 'unlink Vercors' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'unlink Reseau' })).toBeNull(); + expect( + screen.getByRole('button', { name: 'unlink Grotte exemple' }) + ).toBeInTheDocument(); +}); + it('closes the unlink dialog when its entity is removed', async () => { const user = userEvent.setup(); const onUnlink = vi.fn(); diff --git a/packages/web-app/src/hooks/index.js b/packages/web-app/src/hooks/index.js index 3d259b6ce..1f86d9354 100644 --- a/packages/web-app/src/hooks/index.js +++ b/packages/web-app/src/hooks/index.js @@ -83,6 +83,7 @@ export { useDeleteEntrance } from './mutations/useDeleteEntrance'; export { useRestoreEntrance } from './mutations/useRestoreEntrance'; export { useLinkDocumentToEntrance, + useLinkDocumentToEntrances, useLinkDocumentsToEntrance, useUnlinkDocumentToEntrance } from './mutations/useLinkDocumentToEntrance'; @@ -261,6 +262,7 @@ export { default as useDeviceOrientation } from './useDeviceOrientation'; export { useMeasuredHeight } from './useMeasuredHeight'; export { useMeasuredWidth } from './useMeasuredWidth'; export { useLongPress } from './useLongPress'; +export { usePostDeletion } from './usePostDeletion'; export { useOnlineStatus } from './useOnlineStatus'; // Non-RQ reconnect paths: map viewport, intl catalogue and Leaflet tile redraw. export { useRefetchOnReconnect } from './useRefetchOnReconnect'; diff --git a/packages/web-app/src/hooks/mutations/useDocumentAssociations.test.jsx b/packages/web-app/src/hooks/mutations/useDocumentAssociations.test.jsx index 1c444cc34..133e91087 100644 --- a/packages/web-app/src/hooks/mutations/useDocumentAssociations.test.jsx +++ b/packages/web-app/src/hooks/mutations/useDocumentAssociations.test.jsx @@ -11,6 +11,7 @@ import { import { createTestQueryClient } from '@/test/renderWithProviders'; import { useLinkDocumentToEntrance, + useLinkDocumentToEntrances, useLinkDocumentsToEntrance, useUnlinkDocumentToEntrance } from './useLinkDocumentToEntrance'; @@ -72,6 +73,72 @@ describe('document association mutations', () => { }); }); + it('links one document to multiple entrances and refreshes both sides', async () => { + apiPut.mockResolvedValue(null); + const { result } = renderHook(() => useLinkDocumentToEntrances(), { + wrapper: makeWrapper(queryClient) + }); + + await act(async () => { + await result.current.mutateAsync({ + documentId: 234218, + entrances: [{ id: 42 }, { id: 43 }] + }); + }); + + expect(apiPut).toHaveBeenCalledTimes(2); + expect(apiPut).toHaveBeenCalledWith( + associateDocumentToEntranceUrl(42, 234218) + ); + expect(apiPut).toHaveBeenCalledWith( + associateDocumentToEntranceUrl(43, 234218) + ); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: documentKeys.detail(234218) + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: entranceKeys.detail(42) + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: entranceKeys.detail(43) + }); + }); + + it('waits for all entrance links and refreshes after a partial failure', async () => { + const failure = new Error('second entrance failed'); + apiPut + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce(null); + const { result } = renderHook(() => useLinkDocumentToEntrances(), { + wrapper: makeWrapper(queryClient) + }); + let caughtError; + + await act(async () => { + try { + await result.current.mutateAsync({ + documentId: 7, + entrances: [{ id: 1 }, { id: 2 }, { id: 3 }] + }); + } catch (error) { + caughtError = error; + } + }); + + expect(caughtError).toBe(failure); + expect(caughtError.rejections).toEqual([failure]); + expect(apiPut).toHaveBeenCalledTimes(3); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: documentKeys.detail(7) + }); + [1, 2, 3].forEach(id => + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: entranceKeys.detail(id) + }) + ); + }); + it('waits for every entrance link and refreshes the final state on partial failure', async () => { const failure = new Error('second document failed'); apiPut diff --git a/packages/web-app/src/hooks/mutations/useLinkDocumentToEntrance.js b/packages/web-app/src/hooks/mutations/useLinkDocumentToEntrance.js index 8b8e5cd09..3b3e4a27d 100644 --- a/packages/web-app/src/hooks/mutations/useLinkDocumentToEntrance.js +++ b/packages/web-app/src/hooks/mutations/useLinkDocumentToEntrance.js @@ -16,14 +16,12 @@ const invalidateEntranceDocuments = (queryClient, entranceId, documentIds) => ) ]); -const linkDocuments = async (entranceId, documents) => { +const associateAll = async (entities, urlFor) => { // Wait for every request before invalidating. Promise.all would reject on the // first failure while the remaining writes were still running, allowing a // refetch to observe only part of the final server state. const results = await Promise.allSettled( - documents.map(document => - apiPut(associateDocumentToEntranceUrl(entranceId, document.id)) - ) + entities.map(entity => apiPut(urlFor(entity))) ); const rejections = results .filter(result => result.status === 'rejected') @@ -48,11 +46,34 @@ export const useLinkDocumentToEntrance = () => { }); }; +export const useLinkDocumentToEntrances = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ documentId, entrances }) => + associateAll(entrances, entrance => + associateDocumentToEntranceUrl(entrance.id, documentId) + ), + onSettled: (_data, _error, { documentId, entrances }) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: documentKeys.detail(documentId) + }), + ...entrances.map(entrance => + queryClient.invalidateQueries({ + queryKey: entranceKeys.detail(entrance.id) + }) + ) + ]) + }); +}; + export const useLinkDocumentsToEntrance = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ entranceId, documents }) => - linkDocuments(entranceId, documents), + associateAll(documents, document => + associateDocumentToEntranceUrl(entranceId, document.id) + ), onSettled: (_data, _error, { entranceId, documents }) => invalidateEntranceDocuments( queryClient, diff --git a/packages/web-app/src/hooks/usePostDeletion.js b/packages/web-app/src/hooks/usePostDeletion.js new file mode 100644 index 000000000..da5970f5b --- /dev/null +++ b/packages/web-app/src/hooks/usePostDeletion.js @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { getPostDeletionUrl } from '@/utils/deletedEntityRedirect'; + +export const usePostDeletion = ({ entityType, id, entity, deleteMutation }) => { + const navigate = useNavigate(); + const [wantedDeletedState, setWantedDeletedState] = useState( + entity?.isDeleted ?? false + ); + + useEffect(() => { + setWantedDeletedState(entity?.isDeleted ?? false); + }, [id, entity?.isDeleted]); + + const onDeletePress = (entityId, isPermanent = true) => { + setWantedDeletedState(true); + deleteMutation.mutate( + { id, entityId, isPermanent }, + { + onSuccess: () => { + if (isPermanent) { + navigate( + getPostDeletionUrl(entityType, entityId, entity?.redirectTo), + { replace: true } + ); + } + }, + onError: () => setWantedDeletedState(entity?.isDeleted ?? false) + } + ); + }; + + return { onDeletePress, wantedDeletedState, setWantedDeletedState }; +}; diff --git a/packages/web-app/src/hooks/usePostDeletion.test.jsx b/packages/web-app/src/hooks/usePostDeletion.test.jsx new file mode 100644 index 000000000..f40aee871 --- /dev/null +++ b/packages/web-app/src/hooks/usePostDeletion.test.jsx @@ -0,0 +1,84 @@ +import { act, renderHook } from '@testing-library/react'; + +import { usePostDeletion } from './usePostDeletion'; + +const navigate = vi.hoisted(() => vi.fn()); +vi.mock('react-router-dom', () => ({ useNavigate: () => navigate })); + +const entityType = { url: '/ui/entrances/' }; + +describe('post-deletion state and navigation', () => { + beforeEach(() => navigate.mockReset()); + + const setup = (entity = { isDeleted: false }) => { + const deleteMutation = { mutate: vi.fn() }; + return { + deleteMutation, + ...renderHook(() => + usePostDeletion({ entityType, id: 42, entity, deleteMutation }) + ) + }; + }; + + it.each([ + [43, 44, '/ui/entrances/43'], + [undefined, 44, '/ui/entrances/44'], + [undefined, undefined, '/'] + ])( + 'navigates after success with replacement %s and redirect %s', + (replacement, redirectTo, expectedUrl) => { + const { result, deleteMutation } = setup({ isDeleted: true, redirectTo }); + + act(() => result.current.onDeletePress(replacement, true)); + expect(navigate).not.toHaveBeenCalled(); + const [variables, callbacks] = deleteMutation.mutate.mock.calls[0]; + expect(variables).toEqual({ + id: 42, + entityId: replacement, + isPermanent: true + }); + act(() => callbacks.onSuccess()); + expect(navigate).toHaveBeenCalledExactlyOnceWith(expectedUrl, { + replace: true + }); + } + ); + + it.each([false, true])( + 'rolls back to the previous deleted state (%s) on failure', + isDeleted => { + const { result, deleteMutation } = setup({ isDeleted }); + + act(() => result.current.onDeletePress(43)); + expect(result.current.wantedDeletedState).toBe(true); + act(() => deleteMutation.mutate.mock.calls[0][1].onError()); + + expect(result.current.wantedDeletedState).toBe(isDeleted); + expect(navigate).not.toHaveBeenCalled(); + } + ); + + it('keeps the page during a soft deletion', () => { + const { result, deleteMutation } = setup(); + act(() => result.current.onDeletePress(43, false)); + act(() => deleteMutation.mutate.mock.calls[0][1].onSuccess()); + + expect(result.current.wantedDeletedState).toBe(true); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('syncs deleted state after a restore or entity change', () => { + const deleteMutation = { mutate: vi.fn() }; + const { result, rerender } = renderHook( + ({ id, entity }) => + usePostDeletion({ entityType, id, entity, deleteMutation }), + { initialProps: { id: 42, entity: { isDeleted: true } } } + ); + expect(result.current.wantedDeletedState).toBe(true); + rerender({ id: 42, entity: { isDeleted: false } }); + expect(result.current.wantedDeletedState).toBe(false); + act(() => result.current.onDeletePress(44, false)); + rerender({ id: 43, entity: { isDeleted: false } }); + expect(result.current.wantedDeletedState).toBe(false); + }); +}); diff --git a/packages/web-app/src/pages/DocumentDetails/index.jsx b/packages/web-app/src/pages/DocumentDetails/index.jsx index ffbddfccc..e9dd1475e 100644 --- a/packages/web-app/src/pages/DocumentDetails/index.jsx +++ b/packages/web-app/src/pages/DocumentDetails/index.jsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; -import { Box, Breadcrumbs, Skeleton, Typography } from '@mui/material'; +import { Box, Breadcrumbs, Divider, Skeleton, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; import { useNavigate, useParams } from 'react-router-dom'; import { useSelector } from 'react-redux'; @@ -10,7 +10,9 @@ import DeleteIcon from '@mui/icons-material/Delete'; import ManageHistoryIcon from '@mui/icons-material/ManageHistory'; import NewspaperIcon from '@mui/icons-material/Newspaper'; import ShareIcon from '@mui/icons-material/Share'; +import LinkIcon from '@mui/icons-material/Link'; import { NavigateNext } from '@mui/icons-material'; +import { usePostDeletion } from '@/hooks/usePostDeletion'; import { LicenseBadge } from '@/components/common/LicenseTag'; import { DEFAULT_COLLECTION_SORT_ORDER, @@ -20,6 +22,9 @@ import { import { getIssuesYearRange } from '@/utils/documentChildrenLabel'; import { formatDocumentReference } from '@/utils/documentReference'; import LinkedEntityCards from '@/components/common/entitiesList/LinkedEntityCards'; +import SearchEntranceForm from '@/components/appli/SearchEntranceForm'; +import SectionCreateButton from '@/components/common/SectionCreateButton'; +import { EntityIcon } from '@/pages/EntityCreation/entityConfig'; import AppLink from '../../components/common/AppLink'; import BibliographicReference from './BibliographicReference'; @@ -50,6 +55,9 @@ import { useDocumentChildren, useDeleteDocument, useRestoreDocument, + useLinkDocumentToEntrances, + useUnlinkDocumentToEntrance, + useUnlinkDocumentToMassif, useLanguages, useLicenses, findLicenseByName, @@ -150,6 +158,10 @@ const Document = ({ const permissions = usePermissions(); const deleteMutation = useDeleteDocument(); const restoreMutation = useRestoreDocument(); + const linkEntranceMutation = useLinkDocumentToEntrances(); + const unlinkEntranceMutation = useUnlinkDocumentToEntrance(); + const unlinkMassifMutation = useUnlinkDocumentToMassif(); + const [isEntranceSearchVisible, setIsEntranceSearchVisible] = useState(false); const { data: languages = [] } = useLanguages(); const { locale } = useSelector(state => state.intl); const { data: licenses } = useLicenses(); @@ -168,12 +180,26 @@ const Document = ({ useState(false); const [isDeleteConfirmationPermanent, setIsDeleteConfirmationPermanent] = useState(false); - const [wantedDeletedState, setWantedDeletedState] = useState(false); + const { onDeletePress, wantedDeletedState, setWantedDeletedState } = + usePostDeletion({ + entityType: DELETED_ENTITIES.document, + id: documentData?.id, + entity: documentData, + deleteMutation + }); const handleShare = useSharePage(); - useEffect(() => { - if (documentData) setWantedDeletedState(documentData.isDeleted); - }, [documentData]); + const canAssociateEntrances = + permissions.isAuth && + !!documentData && + !documentData.isDeleted && + !wantedDeletedState && + !hideActions; + // Both entrance/unlink-document.js and massif/unlink-document.js in + // grottocenter-api require a moderator to unlink a document. + const canUnlinkLinkedEntities = + permissions.isModerator && canAssociateEntrances; + const isEntranceSearchOpen = canAssociateEntrances && isEntranceSearchVisible; // The document detail only carries the license name; resolve the full license // object (for its deed URL) from the licenses list. @@ -204,11 +230,6 @@ const Document = ({ } } - const onDeletePress = (entityId, isPermanent) => { - setWantedDeletedState(true); - deleteMutation.mutate({ id: documentData.id, entityId, isPermanent }); - if (isPermanent) navigate('/', { replace: true }); - }; const onRestorePress = () => { setWantedDeletedState(false); restoreMutation.mutate({ id: documentData.id }); @@ -329,7 +350,8 @@ const Document = ({ iconType: 'massif', label: entity.name, secondary: formatMessage({ id: 'Massif' }), - url: `/ui/massifs/${entity.id}` + url: `/ui/massifs/${entity.id}`, + canUnlink: true })), ...(documentData.cave ? [ @@ -339,7 +361,8 @@ const Document = ({ iconType: 'network', label: documentData.cave.name, secondary: formatMessage({ id: 'Cave' }), - url: `/ui/caves/${documentData.cave.id}` + url: `/ui/caves/${documentData.cave.id}`, + canUnlink: false } ] : []), @@ -349,12 +372,14 @@ const Document = ({ iconType: 'entrance', label: entity.name, secondary: formatMessage({ id: 'Entrance' }), - url: `/ui/entrances/${entity.id}` + url: `/ui/entrances/${entity.id}`, + canUnlink: true })) ]; }, [documentData, formatMessage]); const { isCollection, isEvent } = documentTypeHelpers; + const hasLinkedEntities = linkedEntities.length > 0; const docType = documentData?.type; const isActionLoading = wantedDeletedState !== documentData?.isDeleted; @@ -497,6 +522,7 @@ const Document = ({ /> - {linkedEntities.length > 0 && ( + {(hasLinkedEntities || canAssociateEntrances) && ( } + icon={ + canAssociateEntrances ? ( + + setIsEntranceSearchVisible(value => !value) + } + label={formatMessage({ id: 'Associate' })} + tooltip={formatMessage({ + id: 'Associate to an entrance' + })} + openTooltip={formatMessage({ id: 'Cancel this search' })} + testId="associate-entrances-button" + icon={ + + } + /> + ) : undefined + } + content={ + <> + {isEntranceSearchOpen && ( + <> + + linkEntranceMutation.mutateAsync({ + documentId: documentData.id, + entrances + }) + } + onSuccess={() => setIsEntranceSearchVisible(false)} + /> + {hasLinkedEntities && } + + )} + {hasLinkedEntities ? ( + { + if (entity.type === 'entrance') { + return unlinkEntranceMutation.mutateAsync({ + entranceId: entity.id, + documentId: documentData.id + }); + } + if (entity.type === 'massif') { + return unlinkMassifMutation.mutateAsync({ + massifId: entity.id, + documentId: documentData.id + }); + } + throw new Error( + `Unsupported document unlink type: ${entity.type}` + ); + } + : undefined + } + isUnlinking={ + unlinkEntranceMutation.isPending || + unlinkMassifMutation.isPending + } + /> + ) : ( + !isEntranceSearchOpen && ( + + ) + )} + + } /> )} diff --git a/packages/web-app/src/pages/DocumentDetails/index.test.jsx b/packages/web-app/src/pages/DocumentDetails/index.test.jsx new file mode 100644 index 000000000..8c51f73e0 --- /dev/null +++ b/packages/web-app/src/pages/DocumentDetails/index.test.jsx @@ -0,0 +1,177 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import { + useDocument, + usePermissions, + useUnlinkDocumentToEntrance, + useUnlinkDocumentToMassif +} from '@/hooks'; +import { renderWithProviders } from '@/test/renderWithProviders'; +import messages from '@/../public/lang/en.json'; +import DocumentDetails from './index'; + +vi.mock('@/hooks', () => ({ + useDocument: vi.fn(), + useDocumentChildren: () => ({ data: [], isPending: false }), + useLanguages: () => ({ data: [], isPending: false }), + useLicenses: () => ({ data: [] }), + findLicenseByName: () => undefined, + usePermissions: vi.fn(), + useDeleteDocument: () => ({ mutate: vi.fn() }), + useRestoreDocument: () => ({ mutate: vi.fn() }), + useLinkDocumentToEntrances: () => ({ mutateAsync: vi.fn() }), + useUnlinkDocumentToEntrance: vi.fn(), + useUnlinkDocumentToMassif: vi.fn(), + useSharePage: () => vi.fn(), + useOnlineStatus: () => true, + useIsDesktopLayout: () => true, + useAnchorScroll: () => {}, + useDebounce: value => value, + useQuickSearch: () => ({ data: { results: [] }, isFetching: false }) +})); + +// These previews/searches are unrelated to linked-card actions. Avoid loading +// PDF workers and the advanced search dependency tree in this focused test. +vi.mock('@/components/common/PdfPreview', () => ({ default: () => null })); +vi.mock('@/components/appli/SearchEntranceForm', () => ({ + default: () => null +})); +vi.mock('@/components/common/CustomIcon', () => ({ default: () => null })); + +// Importing the icon barrel opens thousands of modules on Windows. Keep the +// actual page, linked cards and confirmation dialog, with inert icon artwork. +vi.mock('@mui/icons-material', () => { + const MockIcon = () => null; + return { + Article: MockIcon, + ChevronLeft: MockIcon, + ChevronRight: MockIcon, + EventAvailable: MockIcon, + Image: MockIcon, + InsertDriveFile: MockIcon, + NavigateNext: MockIcon, + PictureAsPdf: MockIcon, + TableChart: MockIcon, + ZoomIn: MockIcon, + ZoomOut: MockIcon + }; +}); + +// Raw document query data: the page must supply canUnlink and route callbacks. +// Sharing IDs across entity types catches accidental use of a foreign endpoint. +const document = { + id: 123, + title: 'Cave survey', + type: 'Article', + isValidated: true, + isDeleted: false, + authors: [], + authorsOrganization: [], + massifs: [{ id: 7, name: 'Vercors' }], + cave: { id: 7, name: 'Survey network' }, + entrances: [{ id: 7, name: 'Survey entrance' }] +}; + +const renderPage = () => + renderWithProviders( + + + } /> + + , + { messages } + ); + +beforeEach(() => { + vi.clearAllMocks(); + useDocument.mockReturnValue({ + data: document, + isPending: false, + isPaused: false, + error: null, + refetch: vi.fn() + }); + usePermissions.mockReturnValue({ isAuth: true, isModerator: true }); + useUnlinkDocumentToEntrance.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false + }); + useUnlinkDocumentToMassif.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false + }); +}); + +it('offers unlink actions for massifs and entrances, but not caves', () => { + renderPage(); + + expect(screen.getByRole('link', { name: /Survey network/ })).toHaveAttribute( + 'href', + '/ui/caves/7' + ); + expect( + screen.queryByRole('button', { name: 'unlink Survey network' }) + ).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'unlink Vercors' })).toBeEnabled(); + expect( + screen.getByRole('button', { name: 'unlink Survey entrance' }) + ).toBeEnabled(); + expect(screen.getAllByRole('button', { name: /^unlink / })).toHaveLength(2); +}); + +it.each([ + [ + 'Vercors', + useUnlinkDocumentToMassif, + useUnlinkDocumentToEntrance, + 'massifId' + ], + [ + 'Survey entrance', + useUnlinkDocumentToEntrance, + useUnlinkDocumentToMassif, + 'entranceId' + ] +])( + 'unlinks %s through its own mutation after confirmation', + async (name, expectedHook, otherHook, idKey) => { + const user = userEvent.setup(); + const expectedMutation = expectedHook().mutateAsync; + const otherMutation = otherHook().mutateAsync; + renderPage(); + + await user.click(screen.getByRole('button', { name: `unlink ${name}` })); + const dialog = screen.getByRole('dialog', { name: 'Unlink' }); + expect(dialog).toHaveTextContent( + `Are you sure you want to unlink ${name}?` + ); + expect(expectedMutation).not.toHaveBeenCalled(); + expect(otherMutation).not.toHaveBeenCalled(); + await user.click(within(dialog).getByRole('button', { name: 'Unlink' })); + + expect(expectedMutation).toHaveBeenCalledExactlyOnceWith({ + [idKey]: 7, + documentId: document.id + }); + expect(otherMutation).not.toHaveBeenCalled(); + } +); + +it.each([true, false])( + 'hides all unlink actions for a non-moderator (authenticated: %s)', + isAuth => { + usePermissions.mockReturnValue({ isAuth, isModerator: false }); + renderPage(); + + expect(screen.getByRole('link', { name: /Vercors/ })).toBeVisible(); + expect(screen.getByRole('link', { name: /Survey network/ })).toBeVisible(); + expect(screen.getByRole('link', { name: /Survey entrance/ })).toBeVisible(); + expect( + screen.queryByRole('button', { name: /^unlink / }) + ).not.toBeInTheDocument(); + expect(useUnlinkDocumentToEntrance().mutateAsync).not.toHaveBeenCalled(); + expect(useUnlinkDocumentToMassif().mutateAsync).not.toHaveBeenCalled(); + } +); diff --git a/packages/web-app/src/pages/DocumentValidation/index.jsx b/packages/web-app/src/pages/DocumentValidation/index.jsx index 202057cbe..a8f98d4bb 100644 --- a/packages/web-app/src/pages/DocumentValidation/index.jsx +++ b/packages/web-app/src/pages/DocumentValidation/index.jsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import { useIntl } from 'react-intl'; -import { isMobileOnly } from 'react-device-detect'; +import { useMediaQuery } from '@mui/material'; import { useDocuments, usePermissions } from '../../hooks'; import Layout from '../../components/common/Layouts/Fixed/FixedContent'; @@ -15,6 +15,10 @@ import EntityTable from '../../components/common/EntityTable'; const DocumentValidationPage = () => { const { formatMessage } = useIntl(); const permissions = usePermissions(); + // Viewport-based rather than UA-based so DevTools emulation, tablets and + // narrow desktop windows all get the fullscreen dialog. Matches the + // `max-width: 600px` media queries used by StandardDialog itself. + const isNarrowViewport = useMediaQuery(theme => theme.breakpoints.down('sm')); const [selectedIds, setSelectedIds] = useState([]); const [page, setPage] = useState(0); @@ -94,7 +98,8 @@ const DocumentValidationPage = () => { /> { { const { formatMessage } = useIntl(); const { openBi, isOpening } = useOpenBi(); + const appVersion = import.meta.env.VITE_APP_VERSION; + const isPublishedVersion = /^\d+\.\d+\.\d+$/.test(appVersion ?? ''); return ( @@ -364,7 +362,18 @@ const Footer = () => { display: 'block', textAlign: 'center' }}> - Grottocenter web v{appVersion} + {isPublishedVersion ? ( + + Grottocenter web v{appVersion} + + ) : ( + `Grottocenter web v${appVersion}` + )}
)} diff --git a/packages/web-app/src/pages/homepage/Footer.test.jsx b/packages/web-app/src/pages/homepage/Footer.test.jsx new file mode 100644 index 000000000..ac3bf9a44 --- /dev/null +++ b/packages/web-app/src/pages/homepage/Footer.test.jsx @@ -0,0 +1,48 @@ +import { screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { renderWithProviders } from '@/test/renderWithProviders'; +import messages from '../../../public/lang/en.json'; +import Footer from './Footer'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +it('links a published version to its matching GitHub release', () => { + vi.stubEnv('VITE_APP_VERSION', '3.2.1'); + + renderWithProviders( + +