diff --git a/src/components/__tests__/TableNotifications.cy.js b/src/components/__tests__/TableNotifications.cy.js new file mode 100644 index 000000000..c20b5bb3f --- /dev/null +++ b/src/components/__tests__/TableNotifications.cy.js @@ -0,0 +1,163 @@ +import { colors, date } from 'quasar'; +import TableNotifications from 'components/profile/TableNotifications.vue'; +import { i18n } from '../../boot/i18n'; + +// colors +const { getPaletteColor } = colors; +const grey10 = getPaletteColor('grey-10'); + +// selectors +const classSelectorQBtn = '.q-btn'; +const dataSelectorNotificationTitle = '[data-cy="notification-title"]'; +const dataSelectorNotificationTimestamp = '[data-cy="notification-timestamp"]'; +const dataSelectorNotificationState = '[data-cy="notification-state"]'; +const dataSelectorNotificationAction = '[data-cy="notification-action"]'; +const dataSelectorNotificationIcon = '[data-cy="notification-icon"]'; +const dataSelectorNotificationVerbal = '[data-cy="notification-verbal"]'; +const selectorTableNotifications = 'table-notifications'; +const selectorNotificationRow = 'notification-row'; +const selectorButtonMarkAllAsRead = 'button-mark-all-as-read'; + +// variables +const defaultTablePostsPerPage = 5; +const fontWeightBold = '700'; +const fontWeightRegular = '400'; + +describe('', () => { + it('has translation for all strings', () => { + cy.testLanguageStringsInContext( + [ + 'labelAction', + 'labelDate', + 'labelRead', + 'labelState', + 'labelTitle', + 'labelUnread', + ], + 'notifications', + i18n, + ); + }); + + let notifications; + + before(() => { + cy.fixture('tableNotifications').then((data) => { + notifications = data; + }); + }); + + context('desktop', () => { + beforeEach(() => { + cy.mount(TableNotifications, { + props: {}, + }); + cy.viewport('macbook-16'); + }); + + coreTests(); + }); + + function coreTests() { + it('renders component', () => { + cy.dataCy(selectorTableNotifications).should('be.visible'); + }); + + it('renders notifications', () => { + cy.dataCy(selectorNotificationRow) + .should('be.visible') + .should('have.length', defaultTablePostsPerPage); + cy.dataCy(selectorNotificationRow).each((row, index) => { + const notification = notifications[index]; + cy.wrap(row) + .find(dataSelectorNotificationTitle) + .should('be.visible') + .should('contain', notification.verb); + cy.wrap(row).find(dataSelectorNotificationIcon).should('be.visible'); + cy.wrap(row).find(dataSelectorNotificationVerbal).should('be.visible'); + if (notification.data.url) { + cy.wrap(row) + .find(dataSelectorNotificationVerbal) + .and('have.prop', 'tagName', 'A') + .and('have.attr', 'href', notification.data.url) + .and('have.attr', 'target', '_blank'); + } else { + cy.wrap(row) + .find(dataSelectorNotificationVerbal) + .should('have.prop', 'tagName', 'SPAN') + .and('have.color', grey10); + } + if (notification.unread) { + cy.wrap(row) + .find(dataSelectorNotificationVerbal) + .should('have.css', 'font-weight', fontWeightBold); + } else { + cy.wrap(row) + .find(dataSelectorNotificationVerbal) + .should('have.css', 'font-weight', fontWeightRegular); + } + cy.wrap(row) + .find(dataSelectorNotificationTimestamp) + .should('be.visible') + .and( + 'contain', + date.formatDate( + new Date(String(notification.timestamp)), + 'D. MMM. YYYY', + ), + ); + cy.wrap(row) + .find(dataSelectorNotificationState) + .should('be.visible') + .and( + 'contain', + notification.unread + ? i18n.global.t('notifications.labelUnread') + : i18n.global.t('notifications.labelRead'), + ); + cy.wrap(row).find(dataSelectorNotificationAction).should('be.visible'); + }); + }); + + it('allows to mark notification as read', () => { + cy.dataCy(selectorNotificationRow) + .first() + .find(dataSelectorNotificationState) + .should('contain', i18n.global.t('notifications.labelUnread')); + cy.dataCy(selectorNotificationRow) + .first() + .find(dataSelectorNotificationAction) + .find(classSelectorQBtn) + .click(); + cy.dataCy(selectorNotificationRow) + .first() + .find(dataSelectorNotificationState) + .should('not.contain', i18n.global.t('notifications.labelUnread')) + .and('contain', i18n.global.t('notifications.labelRead')); + }); + + it('marks notification as read when row is clicked', () => { + cy.dataCy(selectorNotificationRow).last().click(); + cy.dataCy(selectorNotificationRow) + .last() + .find(dataSelectorNotificationState) + .should('not.contain', i18n.global.t('notifications.labelUnread')) + .and('contain', i18n.global.t('notifications.labelRead')); + }); + + it('allows to mark all notifications as read', () => { + cy.dataCy(selectorButtonMarkAllAsRead) + .should('be.visible') + .and('not.be.disabled') + .click(); + cy.dataCy(selectorNotificationRow).each((row) => { + cy.wrap(row) + .find(dataSelectorNotificationState) + .should('contain', i18n.global.t('notifications.labelRead')); + }); + cy.dataCy(selectorButtonMarkAllAsRead) + .should('be.visible') + .and('be.disabled'); + }); + } +}); diff --git a/src/components/profile/ProfileTabs.vue b/src/components/profile/ProfileTabs.vue index 39c81cdb8..378f72449 100644 --- a/src/components/profile/ProfileTabs.vue +++ b/src/components/profile/ProfileTabs.vue @@ -7,6 +7,7 @@ * * @components * - `ProfileDetails`: Component to display a ProfileDetails section. + * - `TableNotifications`: Component to display a table of notifications. * * @example * @@ -19,6 +20,7 @@ import { defineComponent, ref } from 'vue'; // components import ProfileDetails from './ProfileDetails.vue'; +import TableNotifications from './TableNotifications.vue'; // routes import { routesConf } from '../../router/routes_conf'; @@ -35,6 +37,7 @@ export default defineComponent({ name: 'ProfileTabs', components: { ProfileDetails, + TableNotifications, }, setup() { const activeTab = ref(tabsProfile.none); @@ -113,7 +116,7 @@ export default defineComponent({ :name="tabsProfile.notifications" data-cy="profile-tabs-panel-notifications" > - + diff --git a/src/components/profile/TableNotifications.vue b/src/components/profile/TableNotifications.vue new file mode 100644 index 000000000..3315e77b4 --- /dev/null +++ b/src/components/profile/TableNotifications.vue @@ -0,0 +1,229 @@ + + + diff --git a/src/components/types/Notifications.ts b/src/components/types/Notifications.ts new file mode 100644 index 000000000..21bbd8ba5 --- /dev/null +++ b/src/components/types/Notifications.ts @@ -0,0 +1,15 @@ +export interface Notification { + mark_as_read: string; + mark_as_unread: string; + id: number; + level: string; + unread: boolean; + deleted: boolean; + verb: string; + description: string | null; + timestamp: string; + data: { + url: string; + icon: string; + }; +} diff --git a/src/components/types/Table.ts b/src/components/types/Table.ts index 9c1d62b5f..cb856c771 100644 --- a/src/components/types/Table.ts +++ b/src/components/types/Table.ts @@ -1,11 +1,21 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// this rule is disabled because we are copying Quasar table column type. + export type TableColumn = { - align: string; - field: string; - format?: (val: number | string | null) => string; - label: string; name: string; - required: boolean; - sortable: boolean; + label: string; + field: string | ((row: any) => any); + required?: boolean; + align?: 'left' | 'right' | 'center'; + sortable?: boolean; + sort?: (a: any, b: any, rowA: any, rowB: any) => number; + rawSort?: (a: any, b: any, rowA: any, rowB: any) => number; + sortOrder?: 'ad' | 'da'; + format?: (val: any, row: any) => any; + style?: string | ((row: any) => string); + classes?: string | ((row: any) => string); + headerStyle?: string; + headerClasses?: string; }; export type TableRow = { diff --git a/src/i18n/cs.toml b/src/i18n/cs.toml index bb5b9c9f4..106274361 100755 --- a/src/i18n/cs.toml +++ b/src/i18n/cs.toml @@ -436,6 +436,15 @@ description = "Stáhněte si appku a zapisujte jízdy odkudkoliv." voucherApplySuccess = "Voucher byl úspěšně uplatněn" voucherApplyError = "Neplatný voucher" +[notifications] +labelAction = "Akce" +labelDate = "Datum" +labelRead = "Přečteno" +labelState = "Stav" +labelTitle = "Notifikace" +labelUnread = "Nepřečteno" +labelMarkAllAsRead = "Označit vše jako přečtené" + [offer] titleOfferValidation = "Pro využití se stačí na místě prokázat:" labelOfferValidationTshirt = "Trikem nebo nákrčníkem" diff --git a/src/i18n/en.toml b/src/i18n/en.toml index aea757667..046c6719e 100755 --- a/src/i18n/en.toml +++ b/src/i18n/en.toml @@ -433,6 +433,15 @@ description = "Download the app and log rides from anywhere." voucherApplySuccess = "Voucher successfully redeemed" voucherApplyError = "Invalid voucher" +[notifications] +labelAction = "Action" +labelDate = "Date" +labelRead = "Read" +labelState = "State" +labelTitle = "Notification" +labelUnread = "Unread" +labelMarkAllAsRead = "Mark all as read" + [offer] titleOfferValidation = "To use it, just show proof of identity on the spot:" labelOfferValidationTshirt = "T-shirt or neck warmer" diff --git a/src/i18n/sk.toml b/src/i18n/sk.toml index 3501f4dba..7db6bbb1f 100755 --- a/src/i18n/sk.toml +++ b/src/i18n/sk.toml @@ -433,6 +433,15 @@ description = "Stiahnite si aplikáciu a prihlasujte jazdy odkiaľkoľvek." voucherApplySuccess = "Voucher bol úspešne uplatnený" voucherApplyError = "Neplatný voucher" +[notifications] +labelAction = "Akcia" +labelDate = "Dátum" +labelRead = "Prečité" +labelState = "Stav" +labelTitle = "Oznámenie" +labelUnread = "Neprečité" +labelMarkAllAsRead = "Označiť všetko ako prečítané" + [offer] titleOfferValidation = "Chcete-li ji použít, stačí se na místě prokázat dokladem totožnosti:" labelOfferValidationTshirt = "Tričko nebo nákrčník" diff --git a/test/cypress/fixtures/tableNotifications.json b/test/cypress/fixtures/tableNotifications.json new file mode 100644 index 000000000..a169c798c --- /dev/null +++ b/test/cypress/fixtures/tableNotifications.json @@ -0,0 +1,77 @@ +[ + { + "mark_as_read": "Mark as read", + "mark_as_unread": "Mark as unread", + "id": 1, + "level": "info", + "unread": true, + "deleted": false, + "verb": "New message", + "description": "You have a new message from your coordinator.", + "timestamp": "2023-05-15T10:30:00Z", + "data": { + "url": "", + "icon": "mail" + } + }, + { + "mark_as_read": "Mark as read", + "mark_as_unread": "Mark as unread", + "id": 2, + "level": "success", + "unread": false, + "deleted": false, + "verb": "Challenge completed", + "description": "Congratulations! You've completed the weekly challenge.", + "timestamp": "2023-05-14T18:45:00Z", + "data": { + "url": "/challenges/weekly", + "icon": "mdi-trophy" + } + }, + { + "mark_as_read": "Mark as read", + "mark_as_unread": "Mark as unread", + "id": 3, + "level": "warning", + "unread": true, + "deleted": false, + "verb": "Profile update required", + "description": "Please update your profile information.", + "timestamp": "2023-05-13T09:15:00Z", + "data": { + "url": "/profile", + "icon": "person" + } + }, + { + "mark_as_read": "Mark as read", + "mark_as_unread": "Mark as unread", + "id": 4, + "level": "error", + "unread": false, + "deleted": false, + "verb": "Payment failed", + "description": "Your recent payment for the challenge registration has failed.", + "timestamp": "2023-05-12T14:20:00Z", + "data": { + "url": "/payments", + "icon": "warning" + } + }, + { + "mark_as_read": "Mark as read", + "mark_as_unread": "Mark as unread", + "id": 5, + "level": "info", + "unread": true, + "deleted": false, + "verb": "New team member", + "description": "A new member has joined your team.", + "timestamp": "2023-05-11T11:00:00Z", + "data": { + "url": "", + "icon": "group_add" + } + } +]