From 962d13598de25bbfa006fa0dda3e469a172c4424 Mon Sep 17 00:00:00 2001 From: Griffin Sullivan <48397354+Griffin-Sullivan@users.noreply.github.com> Date: Thu, 7 Nov 2024 11:00:14 -0500 Subject: [PATCH] Add NotificationContext and useNotification hook (#524) Signed-off-by: Griffin-Sullivan --- clients/ui/frontend/package-lock.json | 11 + clients/ui/frontend/package.json | 1 + .../cypress/pages/components/Notification.ts | 7 + .../cypress/cypress/pages/modelRegistry.ts | 1 - .../cypress/cypress/support/commands/api.ts | 2 +- .../cypress/support/commands/application.ts | 4 +- .../tests/mocked/modelVersionArchive.cy.ts | 256 ++++++++-------- .../cypress/tests/mocked/modelVersions.cy.ts | 2 +- .../tests/mocked/registeredModelArchive.cy.ts | 277 +++++++++--------- clients/ui/frontend/src/app/App.tsx | 2 + .../src/app/context/NotificationContext.tsx | 71 +++++ .../frontend/src/app/hooks/useNotification.ts | 112 +++++++ .../components/ArchiveModelVersionModal.tsx | 5 +- .../ArchiveRegisteredModelModal.tsx | 9 +- .../components/RestoreModelVersionModal.tsx | 5 +- .../components/RestoreRegisteredModel.tsx | 9 +- clients/ui/frontend/src/app/types.ts | 26 ++ .../src/components/ToastNotification.tsx | 50 ++++ .../src/components/ToastNotifications.tsx | 18 ++ clients/ui/frontend/src/index.tsx | 5 +- 20 files changed, 599 insertions(+), 274 deletions(-) create mode 100644 clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Notification.ts create mode 100644 clients/ui/frontend/src/app/context/NotificationContext.tsx create mode 100644 clients/ui/frontend/src/app/hooks/useNotification.ts create mode 100644 clients/ui/frontend/src/components/ToastNotification.tsx create mode 100644 clients/ui/frontend/src/components/ToastNotifications.tsx diff --git a/clients/ui/frontend/package-lock.json b/clients/ui/frontend/package-lock.json index 7adf1048..3cae3116 100644 --- a/clients/ui/frontend/package-lock.json +++ b/clients/ui/frontend/package-lock.json @@ -43,6 +43,7 @@ "@types/dompurify": "^3.0.5", "@types/jest": "^29.5.13", "@types/lodash-es": "^4.17.8", + "@types/react-dom": "^18.3.1", "@types/react-router-dom": "^5.3.3", "@types/showdown": "^2.0.3", "chai-subset": "^1.6.0", @@ -4486,6 +4487,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-router": { "version": "5.1.20", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", diff --git a/clients/ui/frontend/package.json b/clients/ui/frontend/package.json index eadff55f..dc81ee8b 100644 --- a/clients/ui/frontend/package.json +++ b/clients/ui/frontend/package.json @@ -47,6 +47,7 @@ "@types/dompurify": "^3.0.5", "@types/jest": "^29.5.13", "@types/lodash-es": "^4.17.8", + "@types/react-dom": "^18.3.1", "@types/react-router-dom": "^5.3.3", "@types/showdown": "^2.0.3", "chai-subset": "^1.6.0", diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Notification.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Notification.ts new file mode 100644 index 00000000..ac51c5c1 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Notification.ts @@ -0,0 +1,7 @@ +export class ToastNotification { + constructor(private title: string) {} + + find(): Cypress.Chainable> { + return cy.findByText(this.title); + } +} diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts index 8af39893..8984975a 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts @@ -133,7 +133,6 @@ class ModelRegistry { return this.findModelVersionsTable().find('tbody tr'); } - // TODO: Uncomment when the table row is implemented getRow(name: string) { return new ModelRegistryTableRow(() => this.findTable().find(`[data-label="Model name"]`).contains(name).parents('tr'), diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts index ceaef6fd..019f4520 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts @@ -96,7 +96,7 @@ declare global { options: { path: { modelRegistryName: string; apiVersion: string; modelVersionId: number }; }, - response: ApiResponse, + response: ApiResponse>, ) => Cypress.Chainable) & (( type: 'GET /api/:apiVersion/model_registry', diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/application.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/application.ts index 98f42c4a..7b5de43e 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/application.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/application.ts @@ -171,7 +171,7 @@ Cypress.Commands.add( if ($el.attr('aria-expanded') === 'false') { cy.wrap($el).click(); } - return cy.wrap($el.parent()).findByRole('menuitem', { name }); + return cy.get('body').findByRole('menuitem', { name }); }); }, ); @@ -182,7 +182,7 @@ Cypress.Commands.add('findDropdownItem', { prevSubject: 'element' }, (subject, n if ($el.attr('aria-expanded') === 'false') { cy.wrap($el).click(); } - return cy.wrap($el).parent().findByRole('menuitem', { name }); + return cy.get('body').findByRole('menuitem', { name }); }); }); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts index a1a3cef4..f52bdc37 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts @@ -4,13 +4,18 @@ import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; -import { labelModal } from '~/__tests__/cypress/cypress/pages/modelRegistry'; +import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; import type { ModelRegistry, ModelVersion } from '~/app/types'; import { ModelState } from '~/app/types'; import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockBFFResponse } from '~/__mocks__/utils'; -import { modelVersionArchive } from '~/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive'; +import { + archiveVersionModal, + modelVersionArchive, + restoreVersionModal, +} from '~/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive'; import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; +import { ToastNotification } from '~/__tests__/cypress/cypress/pages/components/Notification'; type HandlersProps = { registeredModelsSize?: number; @@ -129,12 +134,10 @@ describe('Model version archive list', () => { initIntercepts({ modelVersions: [mockModelVersion({ id: '3', name: 'model version 2' })] }); modelVersionArchive.visitModelVersionList(); verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions'); - // TODO: Uncomment when dropdowns are fixed and remove the visit after the comments - // modelVersionArchive - // .findModelVersionsTableKebab() - // .findDropdownItem('View archived versions') - // .click(); - modelVersionArchive.visit(); + modelVersionArchive + .findModelVersionsTableKebab() + .findDropdownItem('View archived versions') + .click(); modelVersionArchive.shouldArchiveVersionsEmpty(); }); @@ -185,119 +188,124 @@ describe('Model version archive list', () => { }); }); -// TODO: Uncomment when we have restoring and archiving mocked -// describe('Restoring archive version', () => { -// it('Restore from archive table', () => { -// cy.interceptApi( -// 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', -// { -// path: { -// modelRegistryName: 'modelregistry-sample', -// apiVersion: MODEL_REGISTRY_API_VERSION, -// modelVersionId: 2, -// }, -// }, -// mockModelVersion({}), -// ).as('versionRestored'); - -// initIntercepts({}); -// modelVersionArchive.visit(); - -// const archiveVersionRow = modelVersionArchive.getRow('model version 2'); -// archiveVersionRow.findKebabAction('Restore version').click(); - -// restoreVersionModal.findRestoreButton().click(); - -// cy.wait('@versionRestored').then((interception) => { -// expect(interception.request.body).to.eql({ -// state: 'LIVE', -// }); -// }); -// }); - -// it('Restore from archive version details', () => { -// cy.interceptApi( -// 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', -// { -// path: { -// modelRegistryName: 'modelregistry-sample', -// apiVersion: MODEL_REGISTRY_API_VERSION, -// modelVersionId: 2, -// }, -// }, -// mockModelVersion({}), -// ).as('versionRestored'); - -// initIntercepts({}); -// modelVersionArchive.visitArchiveVersionDetail(); - -// modelVersionArchive.findRestoreButton().click(); -// restoreVersionModal.findRestoreButton().click(); - -// cy.wait('@versionRestored').then((interception) => { -// expect(interception.request.body).to.eql({ -// state: 'LIVE', -// }); -// }); -// }); -// }); - -// describe('Archiving version', () => { -// it('Archive version from versions table', () => { -// cy.interceptApi( -// 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId', -// { -// path: { -// serviceName: 'modelregistry-sample', -// apiVersion: MODEL_REGISTRY_API_VERSION, -// modelVersionId: 3, -// }, -// }, -// mockModelVersion({}), -// ).as('versionArchived'); - -// initIntercepts({}); -// modelVersionArchive.visitModelVersionList(); - -// const modelVersionRow = modelRegistry.getModelVersionRow('model version 3'); -// modelVersionRow.findKebabAction('Archive model version').click(); -// archiveVersionModal.findArchiveButton().should('be.disabled'); -// archiveVersionModal.findModalTextInput().fill('model version 3'); -// archiveVersionModal.findArchiveButton().should('be.enabled').click(); -// cy.wait('@versionArchived').then((interception) => { -// expect(interception.request.body).to.eql({ -// state: 'ARCHIVED', -// }); -// }); -// }); - -// it('Archive version from versions details', () => { -// cy.interceptApi( -// 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId', -// { -// path: { -// serviceName: 'modelregistry-sample', -// apiVersion: MODEL_REGISTRY_API_VERSION, -// modelVersionId: 3, -// }, -// }, -// mockModelVersion({}), -// ).as('versionArchived'); - -// initIntercepts({}); -// modelVersionArchive.visitModelVersionDetails(); -// modelVersionArchive -// .findModelVersionsDetailsHeaderAction() -// .findDropdownItem('Archive version') -// .click(); - -// archiveVersionModal.findArchiveButton().should('be.disabled'); -// archiveVersionModal.findModalTextInput().fill('model version 3'); -// archiveVersionModal.findArchiveButton().should('be.enabled').click(); -// cy.wait('@versionArchived').then((interception) => { -// expect(interception.request.body).to.eql({ -// state: 'ARCHIVED', -// }); -// }); -// }); -// }); +describe('Restoring archive version', () => { + it('Restore from archive table', () => { + cy.interceptApi( + 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 2, + }, + }, + mockBFFResponse(mockModelVersion({})), + ).as('versionRestored'); + + initIntercepts({}); + modelVersionArchive.visit(); + + const archiveVersionRow = modelVersionArchive.getRow('model version 2'); + archiveVersionRow.findKebabAction('Restore version').click(); + + restoreVersionModal.findRestoreButton().click(); + + const notification = new ToastNotification('model version 2 restored.'); + notification.find(); + + cy.wait('@versionRestored').then((interception) => { + expect(interception.request.body).to.eql(mockBFFResponse({ state: 'LIVE' })); + }); + }); + + it('Restore from archive version details', () => { + cy.interceptApi( + 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 2, + }, + }, + mockBFFResponse(mockModelVersion({})), + ).as('versionRestored'); + + initIntercepts({}); + modelVersionArchive.visitArchiveVersionDetail(); + + modelVersionArchive.findRestoreButton().click(); + restoreVersionModal.findRestoreButton().click(); + + const notification = new ToastNotification('model version 2 restored.'); + notification.find(); + + cy.wait('@versionRestored').then((interception) => { + expect(interception.request.body).to.eql(mockBFFResponse({ state: 'LIVE' })); + }); + }); +}); + +describe('Archiving version', () => { + it('Archive version from versions table', () => { + cy.interceptApi( + 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 3, + }, + }, + mockBFFResponse(mockModelVersion({})), + ).as('versionArchived'); + + initIntercepts({}); + modelVersionArchive.visitModelVersionList(); + + const modelVersionRow = modelRegistry.getModelVersionRow('model version 3'); + modelVersionRow.findKebabAction('Archive model version').click(); + archiveVersionModal.findArchiveButton().should('be.disabled'); + archiveVersionModal.findModalTextInput().fill('model version 3'); + archiveVersionModal.findArchiveButton().should('be.enabled').click(); + + const notification = new ToastNotification('model version 3 archived.'); + notification.find(); + + cy.wait('@versionArchived').then((interception) => { + expect(interception.request.body).to.eql(mockBFFResponse({ state: 'ARCHIVED' })); + }); + }); + + it('Archive version from versions details', () => { + cy.interceptApi( + 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 3, + }, + }, + mockBFFResponse(mockModelVersion({})), + ).as('versionArchived'); + + initIntercepts({}); + modelVersionArchive.visitModelVersionDetails(); + modelVersionArchive + .findModelVersionsDetailsHeaderAction() + .findDropdownItem('Archive version') + .click(); + + archiveVersionModal.findArchiveButton().should('be.disabled'); + archiveVersionModal.findModalTextInput().fill('model version 3'); + archiveVersionModal.findArchiveButton().should('be.enabled').click(); + + const notification = new ToastNotification('model version 3 archived.'); + notification.find(); + + cy.wait('@versionArchived').then((interception) => { + expect(interception.request.body).to.eql(mockBFFResponse({ state: 'ARCHIVED' })); + }); + }); +}); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts index 4fca0caa..3aa7f9c5 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts @@ -130,7 +130,7 @@ describe('Model Versions', () => { }); it('Model versions table', () => { - // TODO: Uncomment when we fix finding dropdown items + // TODO: Uncomment when we fix finding listbox items initIntercepts({ modelRegistries: [ diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts index 5abd877d..451b5a7c 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts @@ -11,7 +11,12 @@ import { ModelState } from '~/app/types'; import { mockBFFResponse } from '~/__mocks__/utils'; import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; -import { registeredModelArchive } from '~/__tests__/cypress/cypress/pages/modelRegistryView/registeredModelArchive'; +import { + archiveModelModal, + registeredModelArchive, + restoreModelModal, +} from '~/__tests__/cypress/cypress/pages/modelRegistryView/registeredModelArchive'; +import { ToastNotification } from '~/__tests__/cypress/cypress/pages/components/Notification'; type HandlersProps = { registeredModels?: RegisteredModel[]; @@ -220,136 +225,144 @@ describe('Model archive list', () => { .findRegisteredModelsArchiveTableHeaderButton('Model name') .should(be.sortDescending); }); + + it('Opens the detail page when we select "View Details" from action menu', () => { + initIntercepts({}); + registeredModelArchive.visit(); + const archiveModelRow = registeredModelArchive.getRow('model 2'); + archiveModelRow.findKebabAction('View details').click(); + cy.location('pathname').should( + 'be.equals', + '/modelRegistry/modelregistry-sample/registeredModels/archive/2/details', + ); + }); +}); + +describe('Restoring archive model', () => { + it('Restore from archive models table', () => { + cy.interceptApi( + 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId', + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 2, + }, + }, + mockBFFResponse(mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.LIVE })), + ).as('modelRestored'); + + initIntercepts({}); + registeredModelArchive.visit(); + + const archiveModelRow = registeredModelArchive.getRow('model 2'); + archiveModelRow.findKebabAction('Restore model').click(); + + restoreModelModal.findRestoreButton().click(); + + const notification = new ToastNotification(`model 2 and all its versions restored.`); + notification.find(); + + cy.wait('@modelRestored').then((interception) => { + expect(interception.request.body).to.eql(mockBFFResponse({ state: 'LIVE' })); + }); + }); + + it('Restore from archive model details', () => { + cy.interceptApi( + 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId', + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 2, + }, + }, + mockBFFResponse(mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.LIVE })), + ).as('modelRestored'); + + initIntercepts({}); + registeredModelArchive.visitArchiveModelDetail(); + + registeredModelArchive.findRestoreButton().click(); + restoreModelModal.findRestoreButton().click(); + + const notification = new ToastNotification(`model 2 and all its versions restored.`); + notification.find(); + + cy.wait('@modelRestored').then((interception) => { + expect(interception.request.body).to.eql(mockBFFResponse({ state: 'LIVE' })); + }); + }); }); -// TODO: Uncomment when dropdowns are fixed -// it('Opens the detail page when we select "View Details" from action menu', () => { -// initIntercepts({}); -// registeredModelArchive.visit(); -// const archiveModelRow = registeredModelArchive.getRow('model 2'); -// archiveModelRow.findKebabAction('View details').click(); -// cy.location('pathname').should( -// 'be.equals', -// '/modelRegistry/modelregistry-sample/registeredModels/archive/2/details', -// ); -// }); - -// TODO: Uncomment when we have mock data for restoring and archiving -// describe('Restoring archive model', () => { -// it('Restore from archive models table', () => { -// cy.interceptApi( -// 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId', -// { -// path: { -// modelRegistryName: 'modelregistry-sample', -// apiVersion: MODEL_REGISTRY_API_VERSION, -// registeredModelId: 2, -// }, -// }, -// mockBFFResponse(mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.LIVE })), -// ).as('modelRestored'); - -// initIntercepts({}); -// registeredModelArchive.visit(); - -// const archiveModelRow = registeredModelArchive.getRow('model 2'); -// archiveModelRow.findKebabAction('Restore model').click(); - -// restoreModelModal.findRestoreButton().click(); - -// cy.wait('@modelRestored').then((interception) => { -// expect(interception.request.body).to.eql({ -// state: 'LIVE', -// }); -// }); -// }); - -// it('Restore from archive model details', () => { -// cy.interceptApi( -// 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', -// { -// path: { -// serviceName: 'modelregistry-sample', -// apiVersion: MODEL_REGISTRY_API_VERSION, -// registeredModelId: 2, -// }, -// }, -// mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.LIVE }), -// ).as('modelRestored'); - -// initIntercepts({}); -// registeredModelArchive.visitArchiveModelDetail(); - -// registeredModelArchive.findRestoreButton().click(); -// restoreModelModal.findRestoreButton().click(); - -// cy.wait('@modelRestored').then((interception) => { -// expect(interception.request.body).to.eql({ -// state: 'LIVE', -// }); -// }); -// }); -// }); - -// describe('Archiving model', () => { -// it('Archive model from registered models table', () => { -// cy.interceptApi( -// 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', -// { -// path: { -// serviceName: 'modelregistry-sample', -// apiVersion: MODEL_REGISTRY_API_VERSION, -// registeredModelId: 3, -// }, -// }, -// mockRegisteredModel({ id: '3', name: 'model 3', state: ModelState.ARCHIVED }), -// ).as('modelArchived'); - -// initIntercepts({}); -// registeredModelArchive.visitModelList(); - -// const modelRow = modelRegistry.getRow('model 3'); -// modelRow.findKebabAction('Archive model').click(); -// archiveModelModal.findArchiveButton().should('be.disabled'); -// archiveModelModal.findModalTextInput().fill('model 3'); -// archiveModelModal.findArchiveButton().should('be.enabled').click(); -// cy.wait('@modelArchived').then((interception) => { -// expect(interception.request.body).to.eql({ -// state: 'ARCHIVED', -// }); -// }); -// }); - -// it('Archive model from model details', () => { -// cy.interceptApi( -// 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', -// { -// path: { -// serviceName: 'modelregistry-sample', -// apiVersion: MODEL_REGISTRY_API_VERSION, -// registeredModelId: 3, -// }, -// }, -// mockRegisteredModel({ id: '3', name: 'model 3', state: ModelState.ARCHIVED }), -// ).as('modelArchived'); - -// initIntercepts({}); -// registeredModelArchive.visitModelList(); - -// const modelRow = modelRegistry.getRow('model 3'); -// modelRow.findName().contains('model 3').click(); -// registeredModelArchive -// .findModelVersionsDetailsHeaderAction() -// .findDropdownItem('Archive model') -// .click(); - -// archiveModelModal.findArchiveButton().should('be.disabled'); -// archiveModelModal.findModalTextInput().fill('model 3'); -// archiveModelModal.findArchiveButton().should('be.enabled').click(); -// cy.wait('@modelArchived').then((interception) => { -// expect(interception.request.body).to.eql({ -// state: 'ARCHIVED', -// }); -// }); -// }); -//}); +describe('Archiving model', () => { + it('Archive model from registered models table', () => { + cy.interceptApi( + 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId', + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 3, + }, + }, + mockBFFResponse( + mockRegisteredModel({ id: '3', name: 'model 3', state: ModelState.ARCHIVED }), + ), + ).as('modelArchived'); + + initIntercepts({}); + registeredModelArchive.visitModelList(); + + const modelRow = modelRegistry.getRow('model 3'); + modelRow.findKebabAction('Archive model').click(); + archiveModelModal.findArchiveButton().should('be.disabled'); + archiveModelModal.findModalTextInput().fill('model 3'); + archiveModelModal.findArchiveButton().should('be.enabled').click(); + + const notification = new ToastNotification('model 3 and all its versions archived.'); + notification.find(); + + cy.wait('@modelArchived').then((interception) => { + expect(interception.request.body).to.eql(mockBFFResponse({ state: 'ARCHIVED' })); + }); + }); + + it('Archive model from model details', () => { + cy.interceptApi( + 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId', + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 3, + }, + }, + mockBFFResponse( + mockRegisteredModel({ id: '3', name: 'model 3', state: ModelState.ARCHIVED }), + ), + ).as('modelArchived'); + + initIntercepts({}); + registeredModelArchive.visitModelList(); + + const modelRow = modelRegistry.getRow('model 3'); + modelRow.findName().contains('model 3').click(); + registeredModelArchive + .findModelVersionsDetailsHeaderAction() + .findDropdownItem('Archive model') + .click(); + + archiveModelModal.findArchiveButton().should('be.disabled'); + archiveModelModal.findModalTextInput().fill('model 3'); + archiveModelModal.findArchiveButton().should('be.enabled').click(); + + const notification = new ToastNotification('model 3 and all its versions archived.'); + notification.find(); + + cy.wait('@modelArchived').then((interception) => { + expect(interception.request.body).to.eql(mockBFFResponse({ state: 'ARCHIVED' })); + }); + }); +}); diff --git a/clients/ui/frontend/src/app/App.tsx b/clients/ui/frontend/src/app/App.tsx index 97e644f6..430a9ff0 100644 --- a/clients/ui/frontend/src/app/App.tsx +++ b/clients/ui/frontend/src/app/App.tsx @@ -19,6 +19,7 @@ import { StackItem, } from '@patternfly/react-core'; import { BarsIcon } from '@patternfly/react-icons'; +import ToastNotifications from '~/components/ToastNotifications'; import NavSidebar from './NavSidebar'; import AppRoutes from './AppRoutes'; import { AppContext } from './AppContext'; @@ -112,6 +113,7 @@ const App: React.FC = () => { + ); diff --git a/clients/ui/frontend/src/app/context/NotificationContext.tsx b/clients/ui/frontend/src/app/context/NotificationContext.tsx new file mode 100644 index 00000000..45946e1a --- /dev/null +++ b/clients/ui/frontend/src/app/context/NotificationContext.tsx @@ -0,0 +1,71 @@ +import React, { createContext } from 'react'; +import { Notification, NotificationActionTypes, NotificationAction } from '~/app/types'; + +type NotificationContextProps = { + notifications: Notification[]; + notificationCount: number; + updateNotificationCount: React.Dispatch>; + dispatch: React.Dispatch; +}; + +export const NotificationContext = createContext({ + notifications: [], + notificationCount: 0, + // eslint-disable-next-line @typescript-eslint/no-empty-function + updateNotificationCount: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + dispatch: () => {}, +}); + +const notificationReducer: React.Reducer = ( + notifications, + action, +) => { + switch (action.type) { + case NotificationActionTypes.ADD_NOTIFICATION: { + return [ + ...notifications, + { + status: action.payload.status, + title: action.payload.title, + timestamp: action.payload.timestamp, + message: action.payload.message, + id: action.payload.id, + }, + ]; + } + case NotificationActionTypes.DELETE_NOTIFICATION: { + return notifications.filter((t) => t.id !== action.payload.id); + } + default: { + return notifications; + } + } +}; + +type NotificationContextProviderProps = { + children: React.ReactNode; +}; + +export const NotificationContextProvider: React.FC = ({ + children, +}) => { + const [notifications, dispatch] = React.useReducer(notificationReducer, []); + const [notificationCount, setNotificationCount] = React.useState(0); + + return ( + ({ + notifications, + notificationCount, + updateNotificationCount: setNotificationCount, + dispatch, + }), + [notifications, notificationCount, setNotificationCount, dispatch], + )} + > + {children} + + ); +}; diff --git a/clients/ui/frontend/src/app/hooks/useNotification.ts b/clients/ui/frontend/src/app/hooks/useNotification.ts new file mode 100644 index 00000000..429df5d3 --- /dev/null +++ b/clients/ui/frontend/src/app/hooks/useNotification.ts @@ -0,0 +1,112 @@ +import React, { useContext } from 'react'; +import { AlertVariant } from '@patternfly/react-core'; +import { NotificationActionTypes } from '~/app/types'; +import { NotificationContext } from '~/app/context/NotificationContext'; + +enum NotificationTypes { + SUCCESS = 'success', + ERROR = 'error', + INFO = 'info', + WARNING = 'warning', +} + +type NotificationProps = (title: string, message?: React.ReactNode) => void; + +type NotificationRemoveProps = (id: number | undefined) => void; + +type NotificationTypeFunc = { + [key in NotificationTypes]: NotificationProps; +}; + +interface NotificationFunc extends NotificationTypeFunc { + remove: NotificationRemoveProps; +} + +export const useNotification = (): NotificationFunc => { + const { notificationCount, updateNotificationCount, dispatch } = useContext(NotificationContext); + + const success: NotificationProps = React.useCallback( + (title, message?) => { + updateNotificationCount(notificationCount + 1); + dispatch({ + type: NotificationActionTypes.ADD_NOTIFICATION, + payload: { + status: AlertVariant.success, + title, + timestamp: new Date(), + message, + id: notificationCount, + }, + }); + }, + [dispatch, notificationCount, updateNotificationCount], + ); + + const warning: NotificationProps = React.useCallback( + (title, message?) => { + updateNotificationCount(notificationCount + 1); + dispatch({ + type: NotificationActionTypes.ADD_NOTIFICATION, + payload: { + status: AlertVariant.warning, + title, + timestamp: new Date(), + message, + id: notificationCount, + }, + }); + }, + [dispatch, notificationCount, updateNotificationCount], + ); + + const error: NotificationProps = React.useCallback( + (title, message?) => { + updateNotificationCount(notificationCount + 1); + dispatch({ + type: NotificationActionTypes.ADD_NOTIFICATION, + payload: { + status: AlertVariant.danger, + title, + timestamp: new Date(), + message, + id: notificationCount, + }, + }); + }, + [dispatch, notificationCount, updateNotificationCount], + ); + + const info: NotificationProps = React.useCallback( + (title, message?) => { + updateNotificationCount(notificationCount + 1); + dispatch({ + type: NotificationActionTypes.ADD_NOTIFICATION, + payload: { + status: AlertVariant.info, + title, + timestamp: new Date(), + message, + id: notificationCount, + }, + }); + }, + [dispatch, notificationCount, updateNotificationCount], + ); + + const remove: NotificationRemoveProps = React.useCallback( + (id) => { + dispatch({ + type: NotificationActionTypes.DELETE_NOTIFICATION, + payload: { id }, + }); + }, + [dispatch], + ); + + const notification = React.useMemo( + () => ({ success, error, info, warning, remove }), + [success, error, info, warning, remove], + ); + + return notification; +}; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx index afdf19b8..4e1f8f59 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal.tsx @@ -9,6 +9,7 @@ import { TextInput, } from '@patternfly/react-core'; import DashboardModalFooter from '~/app/components/DashboardModalFooter'; +import { useNotification } from '~/app/hooks/useNotification'; interface ArchiveModelVersionModalProps { onCancel: () => void; @@ -27,6 +28,7 @@ export const ArchiveModelVersionModal: React.FC = const [error, setError] = React.useState(); const [confirmInputValue, setConfirmInputValue] = React.useState(''); const isDisabled = confirmInputValue.trim() !== modelVersionName || isSubmitting; + const notification = useNotification(); const onClose = React.useCallback(() => { setConfirmInputValue(''); @@ -39,6 +41,7 @@ export const ArchiveModelVersionModal: React.FC = try { await onSubmit(); onClose(); + notification.success(`${modelVersionName} archived.`); } catch (e) { if (e instanceof Error) { setError(e); @@ -46,7 +49,7 @@ export const ArchiveModelVersionModal: React.FC = } finally { setIsSubmitting(false); } - }, [onSubmit, onClose]); + }, [notification, modelVersionName, onSubmit, onClose]); const description = ( <> diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx index 8809db58..3729255d 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx @@ -9,8 +9,7 @@ import { TextInput, } from '@patternfly/react-core'; import DashboardModalFooter from '~/app/components/DashboardModalFooter'; - -// import useNotification from '~/utilities/useNotification'; // TODO: Implement useNotification +import { useNotification } from '~/app/hooks/useNotification'; interface ArchiveRegisteredModelModalProps { onCancel: () => void; @@ -25,7 +24,7 @@ export const ArchiveRegisteredModelModal: React.FC { - // const notification = useNotification(); + const notification = useNotification(); const [isSubmitting, setIsSubmitting] = React.useState(false); const [error, setError] = React.useState(); const [confirmInputValue, setConfirmInputValue] = React.useState(''); @@ -42,7 +41,7 @@ export const ArchiveRegisteredModelModal: React.FC diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx index dad086a0..b8310509 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreModelVersionModal.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Form, Modal, ModalHeader, ModalBody, Alert } from '@patternfly/react-core'; import DashboardModalFooter from '~/app/components/DashboardModalFooter'; +import { useNotification } from '~/app/hooks/useNotification'; interface RestoreModelVersionModalProps { onCancel: () => void; @@ -17,6 +18,7 @@ export const RestoreModelVersionModal: React.FC = }) => { const [isSubmitting, setIsSubmitting] = React.useState(false); const [error, setError] = React.useState(); + const notification = useNotification(); const onClose = React.useCallback(() => { onCancel(); @@ -28,6 +30,7 @@ export const RestoreModelVersionModal: React.FC = try { await onSubmit(); onClose(); + notification.success(`${modelVersionName} restored.`); } catch (e) { if (e instanceof Error) { setError(e); @@ -35,7 +38,7 @@ export const RestoreModelVersionModal: React.FC = } finally { setIsSubmitting(false); } - }, [onSubmit, onClose]); + }, [notification, modelVersionName, onSubmit, onClose]); const description = ( <> diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx index 6f97248a..ee5a23f0 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import { Alert, Form, ModalHeader, Modal, ModalBody } from '@patternfly/react-core'; import DashboardModalFooter from '~/app/components/DashboardModalFooter'; - -// import useNotification from '~/utilities/useNotification'; TODO: Implement useNotification +import { useNotification } from '~/app/hooks/useNotification'; interface RestoreRegisteredModelModalProps { onCancel: () => void; @@ -17,7 +16,7 @@ export const RestoreRegisteredModelModal: React.FC { - // const notification = useNotification(); + const notification = useNotification(); const [isSubmitting, setIsSubmitting] = React.useState(false); const [error, setError] = React.useState(); @@ -31,7 +30,7 @@ export const RestoreRegisteredModelModal: React.FC diff --git a/clients/ui/frontend/src/app/types.ts b/clients/ui/frontend/src/app/types.ts index 1a843d43..9dd9cb4c 100644 --- a/clients/ui/frontend/src/app/types.ts +++ b/clients/ui/frontend/src/app/types.ts @@ -1,3 +1,4 @@ +import { AlertVariant } from '@patternfly/react-core'; import { APIOptions } from '~/app/api/types'; export enum ModelState { @@ -197,3 +198,28 @@ export type ModelRegistryAPIs = { patchRegisteredModel: PatchRegisteredModel; patchModelVersion: PatchModelVersion; }; + +export type Notification = { + id?: number; + status: AlertVariant; + title: string; + message?: React.ReactNode; + hidden?: boolean; + read?: boolean; + timestamp: Date; +}; + +export enum NotificationActionTypes { + ADD_NOTIFICATION = 'add_notification', + DELETE_NOTIFICATION = 'delete_notification', +} + +export type NotificationAction = + | { + type: NotificationActionTypes.ADD_NOTIFICATION; + payload: Notification; + } + | { + type: NotificationActionTypes.DELETE_NOTIFICATION; + payload: { id: Notification['id'] }; + }; diff --git a/clients/ui/frontend/src/components/ToastNotification.tsx b/clients/ui/frontend/src/components/ToastNotification.tsx new file mode 100644 index 00000000..7f34e741 --- /dev/null +++ b/clients/ui/frontend/src/components/ToastNotification.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Alert, AlertActionCloseButton, AlertVariant } from '@patternfly/react-core'; +import { Notification } from '~/app/types'; +import { asEnumMember } from '~/app/utils'; +import { useNotification } from '~/app/hooks/useNotification'; + +const TOAST_NOTIFICATION_TIMEOUT = 8 * 1000; + +interface ToastNotificationProps { + notification: Notification; +} + +const ToastNotification: React.FC = ({ notification }) => { + const notifications = useNotification(); + const [timedOut, setTimedOut] = React.useState(false); + const [mouseOver, setMouseOver] = React.useState(false); + + React.useEffect(() => { + const handle = setTimeout(() => { + setTimedOut(true); + }, TOAST_NOTIFICATION_TIMEOUT); + return () => { + clearTimeout(handle); + }; + }, [setTimedOut]); + + React.useEffect(() => { + if (!notification.hidden && timedOut && !mouseOver) { + notifications.remove(notification.id); + } + }, [mouseOver, notification, timedOut, notifications]); + + if (notification.hidden) { + return null; + } + + return ( + notifications.remove(notification.id)} />} + onMouseEnter={() => setMouseOver(true)} + onMouseLeave={() => setMouseOver(false)} + > + {notification.message} + + ); +}; + +export default ToastNotification; diff --git a/clients/ui/frontend/src/components/ToastNotifications.tsx b/clients/ui/frontend/src/components/ToastNotifications.tsx new file mode 100644 index 00000000..35da1bef --- /dev/null +++ b/clients/ui/frontend/src/components/ToastNotifications.tsx @@ -0,0 +1,18 @@ +import React, { useContext } from 'react'; +import { AlertGroup } from '@patternfly/react-core'; +import { NotificationContext } from '~/app/context/NotificationContext'; +import ToastNotification from './ToastNotification'; + +const ToastNotifications: React.FC = () => { + const { notifications } = useContext(NotificationContext); + + return ( + + {notifications.map((notification) => ( + + ))} + + ); +}; + +export default ToastNotifications; diff --git a/clients/ui/frontend/src/index.tsx b/clients/ui/frontend/src/index.tsx index 333ea347..060875bd 100644 --- a/clients/ui/frontend/src/index.tsx +++ b/clients/ui/frontend/src/index.tsx @@ -4,6 +4,7 @@ import { BrowserRouter as Router } from 'react-router-dom'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import App from './app/App'; import { BrowserStorageContextProvider } from './components/browserStorage/BrowserStorageContext'; +import { NotificationContextProvider } from './app/context/NotificationContext'; const theme = createTheme({ cssVariables: true }); const root = ReactDOM.createRoot(document.getElementById('root')!); @@ -13,7 +14,9 @@ root.render( - + + +