diff --git a/clients/ui/bff/api/registered_models_handler.go b/clients/ui/bff/api/registered_models_handler.go index 6a27215b..dc28ff1f 100644 --- a/clients/ui/bff/api/registered_models_handler.go +++ b/clients/ui/bff/api/registered_models_handler.go @@ -4,11 +4,12 @@ import ( "encoding/json" "errors" "fmt" + "net/http" + "github.com/julienschmidt/httprouter" "github.com/kubeflow/model-registry/pkg/openapi" "github.com/kubeflow/model-registry/ui/bff/integrations" "github.com/kubeflow/model-registry/ui/bff/validation" - "net/http" ) type RegisteredModelEnvelope Envelope[*openapi.RegisteredModel, None] diff --git a/clients/ui/bff/internals/mocks/static_data_mock.go b/clients/ui/bff/internals/mocks/static_data_mock.go index d534fde5..a156c8d5 100644 --- a/clients/ui/bff/internals/mocks/static_data_mock.go +++ b/clients/ui/bff/internals/mocks/static_data_mock.go @@ -29,7 +29,19 @@ func GetRegisteredModelMocks() []openapi.RegisteredModel { State: stateToPointer(openapi.REGISTEREDMODELSTATE_LIVE), } - return []openapi.RegisteredModel{model1, model2} + model3 := openapi.RegisteredModel{ + CustomProperties: newCustomProperties(), + Name: "Model Three", + Description: stringToPointer("This model does things and stuff"), + ExternalId: stringToPointer("345235989"), + Id: stringToPointer("3"), + CreateTimeSinceEpoch: stringToPointer("1725282249933"), + LastUpdateTimeSinceEpoch: stringToPointer("1725282249933"), + Owner: stringToPointer("M. Oriarty"), + State: stateToPointer(openapi.REGISTEREDMODELSTATE_ARCHIVED), + } + + return []openapi.RegisteredModel{model1, model2, model3} } func GetRegisteredModelListMock() openapi.RegisteredModelList { @@ -135,10 +147,28 @@ func GetModelArtifactListMock() openapi.ModelArtifactList { func newCustomProperties() *map[string]openapi.MetadataValue { result := map[string]openapi.MetadataValue{ - "my-label9": { + "tensorflow": { + MetadataStringValue: &openapi.MetadataStringValue{ + StringValue: "", + MetadataType: "MetadataStringValue", + }, + }, + "pytorch": { + MetadataStringValue: &openapi.MetadataStringValue{ + StringValue: "", + MetadataType: "MetadataStringValue", + }, + }, + "mll": { + MetadataStringValue: &openapi.MetadataStringValue{ + StringValue: "", + MetadataType: "MetadataStringValue", + }, + }, + "rnn": { MetadataStringValue: &openapi.MetadataStringValue{ - StringValue: "property9", - MetadataType: "string", + StringValue: "", + MetadataType: "MetadataStringValue", }, }, } diff --git a/clients/ui/frontend/docs/architecture.md b/clients/ui/frontend/docs/architecture.md index c18228f4..5ad39411 100644 --- a/clients/ui/frontend/docs/architecture.md +++ b/clients/ui/frontend/docs/architecture.md @@ -1,3 +1,7 @@ # Model Registry UI Architecture -[TBD] \ No newline at end of file +## Overview + +![Overview](./meta/arch-overview.png) + +[TBD] diff --git a/clients/ui/frontend/docs/meta/arch-overview.png b/clients/ui/frontend/docs/meta/arch-overview.png new file mode 100644 index 00000000..bfdfb906 Binary files /dev/null and b/clients/ui/frontend/docs/meta/arch-overview.png differ diff --git a/clients/ui/frontend/package-lock.json b/clients/ui/frontend/package-lock.json index fec36417..7dbae7b0 100644 --- a/clients/ui/frontend/package-lock.json +++ b/clients/ui/frontend/package-lock.json @@ -12,11 +12,15 @@ "@patternfly/react-core": "6.0.0-alpha.102", "@patternfly/react-icons": "6.0.0-alpha.37", "@patternfly/react-styles": "6.0.0-alpha.35", - "lodash-es": "^4.17.21", + "@patternfly/react-table": "6.0.0-alpha.101", + "classnames": "^2.2.6", + "dompurify": "^2.2.6", + "lodash-es": "^4.17.15", "npm-run-all": "^4.1.5", "react": "^18", "react-dom": "^18", - "sass": "^1.78.0" + "sass": "^1.78.0", + "showdown": "^2.1.0" }, "devDependencies": { "@babel/preset-env": "^7.21.5", @@ -28,8 +32,12 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "14.5.2", + "@types/classnames": "^2.3.1", + "@types/dompurify": "^2.2.6", "@types/jest": "^29.5.12", + "@types/lodash-es": "^4.17.8", "@types/react-router-dom": "^5.3.3", + "@types/showdown": "^2.0.3", "chai-subset": "^1.6.0", "copy-webpack-plugin": "^12.0.2", "core-js": "^3.37.1", @@ -3607,6 +3615,24 @@ "integrity": "sha512-9ddQpDJ1CXDbsuV5lYmynw8hqGncKXxnhNwvUKc+s/i50pNBAMmNO9CP5dkKhnZPcjHQj0A35aleQ7xdRgNWQw==", "license": "MIT" }, + "node_modules/@patternfly/react-table": { + "version": "6.0.0-alpha.101", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.0.0-alpha.101.tgz", + "integrity": "sha512-nR5UsFsht0ZtoAXH69mJGKx+H/o6GFZ1fjiTWoP6Mt4Rz5T0k/OyGmlcothJb4zMc+kjDdhDZIKIqQAF+qyqmA==", + "license": "MIT", + "dependencies": { + "@patternfly/react-core": "^6.0.0-alpha.100", + "@patternfly/react-icons": "^6.0.0-alpha.35", + "@patternfly/react-styles": "^6.0.0-alpha.34", + "@patternfly/react-tokens": "^6.0.0-alpha.34", + "lodash": "^4.17.21", + "tslib": "^2.6.3" + }, + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18" + } + }, "node_modules/@patternfly/react-tokens": { "version": "6.0.0-prerelease.1", "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.0.0-prerelease.1.tgz", @@ -4077,6 +4103,17 @@ "@types/node": "*" } }, + "node_modules/@types/classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-zeOWb0JGBoVmlQoznvqXbE0tEC/HONsnoUNH19Hc96NFsTAwTXbTqb8FMYkru1F/iqp7a18Ws3nWJvtA1sHD1A==", + "deprecated": "This is a stub types definition. classnames provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "classnames": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -4098,6 +4135,16 @@ "@types/node": "*" } }, + "node_modules/@types/dompurify": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-2.4.0.tgz", + "integrity": "sha512-IDBwO5IZhrKvHFUl+clZxgf3hn2b/lU6H1KaBShPkQyGJUQ0xwebezIPSuiyGwfz1UzJWQl4M7BDxtHtCCPlTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -4271,6 +4318,23 @@ "license": "MIT", "optional": true }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4393,6 +4457,13 @@ "@types/send": "*" } }, + "node_modules/@types/showdown": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/showdown/-/showdown-2.0.6.tgz", + "integrity": "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -4431,6 +4502,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", @@ -6588,6 +6666,12 @@ "dev": true, "license": "MIT" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -8303,6 +8387,12 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.6.tgz", + "integrity": "sha512-zUTaUBO8pY4+iJMPE1B9XlO2tXVYIcEA4SNGtvDELzTSCQO7RzH+j7S180BmhmJId78lqGU2z19vgVx2Sxs/PQ==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -15014,7 +15104,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash-es": { @@ -19758,6 +19847,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "license": "MIT", + "dependencies": { + "commander": "^9.0.0" + }, + "bin": { + "showdown": "bin/showdown.js" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/tiviesantos" + } + }, + "node_modules/showdown/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", diff --git a/clients/ui/frontend/package.json b/clients/ui/frontend/package.json index 7504ff06..ce958388 100644 --- a/clients/ui/frontend/package.json +++ b/clients/ui/frontend/package.json @@ -42,6 +42,10 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "^29.5.12", "@types/react-router-dom": "^5.3.3", + "@types/classnames": "^2.3.1", + "@types/dompurify": "^2.2.6", + "@types/showdown": "^2.0.3", + "@types/lodash-es": "^4.17.8", "chai-subset": "^1.6.0", "copy-webpack-plugin": "^12.0.2", "core-js": "^3.37.1", @@ -89,11 +93,15 @@ "@patternfly/react-core": "6.0.0-alpha.102", "@patternfly/react-icons": "6.0.0-alpha.37", "@patternfly/react-styles": "6.0.0-alpha.35", - "lodash-es": "^4.17.21", + "@patternfly/react-table": "6.0.0-alpha.101", + "lodash-es": "^4.17.15", "npm-run-all": "^4.1.5", "react": "^18", "react-dom": "^18", - "sass": "^1.78.0" + "sass": "^1.78.0", + "dompurify": "^2.2.6", + "showdown": "^2.1.0", + "classnames": "^2.2.6" }, "optionalDependencies": { "@typescript-eslint/eslint-plugin": "^8.5.0", diff --git a/clients/ui/frontend/src/__mocks__/mockBFFResponse.ts b/clients/ui/frontend/src/__mocks__/mockBFFResponse.ts new file mode 100644 index 00000000..8b2f910b --- /dev/null +++ b/clients/ui/frontend/src/__mocks__/mockBFFResponse.ts @@ -0,0 +1,5 @@ +import { ModelRegistryResponse } from '~/app/types'; + +export const mockBFFResponse = (data: T): ModelRegistryResponse => ({ + data, +}); diff --git a/clients/ui/frontend/src/__mocks__/mockModelRegistryResponse.ts b/clients/ui/frontend/src/__mocks__/mockModelRegistryResponse.ts deleted file mode 100644 index 79b1e197..00000000 --- a/clients/ui/frontend/src/__mocks__/mockModelRegistryResponse.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable camelcase */ -import { ModelRegistryResponse } from '~/app/types'; - -export const mockModelRegistryResponse = ({ - model_registry = [], -}: Partial): ModelRegistryResponse => ({ - model_registry, -}); diff --git a/clients/ui/frontend/src/__mocks__/utils.ts b/clients/ui/frontend/src/__mocks__/utils.ts index 05406788..1500da7d 100644 --- a/clients/ui/frontend/src/__mocks__/utils.ts +++ b/clients/ui/frontend/src/__mocks__/utils.ts @@ -1,4 +1,8 @@ -import { ModelRegistryMetadataType, ModelRegistryStringCustomProperties } from '~/app/types'; +import { + ModelRegistryMetadataType, + ModelRegistryResponse, + ModelRegistryStringCustomProperties, +} from '~/app/types'; export const createModelRegistryLabelsObject = ( labels: string[], @@ -11,3 +15,7 @@ export const createModelRegistryLabelsObject = ( }; return acc; }, {} as ModelRegistryStringCustomProperties); + +export const mockBFFResponse = (data: T): ModelRegistryResponse => ({ + data, +}); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Contextual.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Contextual.ts new file mode 100644 index 00000000..fec632f4 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Contextual.ts @@ -0,0 +1,7 @@ +export class Contextual { + constructor(private parentSelector: () => Cypress.Chainable>) {} + + find(): Cypress.Chainable> { + return this.parentSelector(); + } +} diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Modal.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Modal.ts new file mode 100644 index 00000000..a98a2239 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/Modal.ts @@ -0,0 +1,29 @@ +import type { ByRoleOptions } from '@testing-library/react'; + +export class Modal { + constructor(private title: ByRoleOptions['name']) {} + + shouldBeOpen(open = true): void { + if (open) { + this.find().testA11y(); + } else { + this.find().should('not.exist'); + } + } + + find(): Cypress.Chainable> { + return cy.findByRole('dialog', { name: this.title }); + } + + findCloseButton(): Cypress.Chainable> { + return this.find().findByRole('button', { name: 'Close' }); + } + + findCancelButton(): Cypress.Chainable> { + return this.findFooter().findByRole('button', { name: 'Cancel' }); + } + + findFooter(): Cypress.Chainable> { + return this.find().find('footer'); + } +} diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/table.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/table.ts new file mode 100644 index 00000000..7915e8d2 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/components/table.ts @@ -0,0 +1,26 @@ +import { Contextual } from '~/__tests__/cypress/cypress/pages/components/Contextual'; + +export class TableRow extends Contextual { + findExpandButton(): Cypress.Chainable> { + return this.find().findByRole('button', { name: 'Details' }); + } + + findRowCheckbox(): Cypress.Chainable> { + return this.find().find(`[data-label=Checkbox]`).find('input'); + } + + shouldBeMarkedForDeletion(): this { + this.find() + .findByRole('button', { name: 'This resource is marked for deletion.' }) + .should('exist'); + return this; + } + + findKebabAction(name: string): Cypress.Chainable> { + return this.find().findKebabAction(name); + } + + findKebab(): Cypress.Chainable> { + return this.find().findKebab(); + } +} 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 9133b2ce..bffc77fe 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts @@ -1,58 +1,56 @@ import { appChrome } from '~/__tests__/cypress/cypress/pages/appChrome'; -// import { TableRow } from './components/table'; -// import { Modal } from './components/Modal'; - -// TODO: Uncomment when the modal is implemented -// class LabelModal extends Modal { -// constructor() { -// super('Labels'); -// } - -// findModalSearchInput() { -// return cy.findByTestId('label-modal-search'); -// } - -// findCloseModal() { -// return cy.findByTestId('close-modal'); -// } - -// shouldContainsModalLabels(labels: string[]) { -// cy.findByTestId('modal-label-group').within(() => labels.map((label) => cy.contains(label))); -// return this; -// } -// } - -// TODO: Uncomment when the table is implemented -// class ModelRegistryTableRow extends TableRow { -// findName() { -// return this.find().findByTestId('model-name'); -// } - -// findDescription() { -// return this.find().findByTestId('description'); -// } - -// findOwner() { -// return this.find().findByTestId('registered-model-owner'); -// } - -// findLabelPopoverText() { -// return this.find().findByTestId('popover-label-text'); -// } - -// findLabelModalText() { -// return this.find().findByTestId('modal-label-text'); -// } - -// shouldContainsPopoverLabels(labels: string[]) { -// cy.findByTestId('popover-label-group').within(() => labels.map((label) => cy.contains(label))); -// return this; -// } - -// findModelVersionName() { -// return this.find().findByTestId('model-version-name'); -// } -// } +import { TableRow } from './components/table'; +import { Modal } from './components/Modal'; + +class LabelModal extends Modal { + constructor() { + super('Labels'); + } + + findModalSearchInput() { + return cy.findByTestId('label-modal-search'); + } + + findCloseModal() { + return cy.findByTestId('close-modal'); + } + + shouldContainsModalLabels(labels: string[]) { + cy.findByTestId('modal-label-group').within(() => labels.map((label) => cy.contains(label))); + return this; + } +} + +class ModelRegistryTableRow extends TableRow { + findName() { + return this.find().findByTestId('model-name'); + } + + findDescription() { + return this.find().findByTestId('description'); + } + + findOwner() { + return this.find().findByTestId('registered-model-owner'); + } + + findLabelPopoverText() { + return this.find().findByTestId('popover-label-text'); + } + + findLabelModalText() { + return this.find().findByTestId('modal-label-text'); + } + + shouldContainsPopoverLabels(labels: string[]) { + cy.findByTestId('popover-label-group').within(() => labels.map((label) => cy.contains(label))); + return this; + } + + findModelVersionName() { + return this.find().findByTestId('model-version-name'); + } +} class ModelRegistry { landingPage() { @@ -132,20 +130,20 @@ class ModelRegistry { } // 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'), - // ); - // } - - // getModelVersionRow(name: string) { - // return new ModelRegistryTableRow(() => - // this.findModelVersionsTable() - // .find(`[data-label="Version name"]`) - // .contains(name) - // .parents('tr'), - // ); - // } + getRow(name: string) { + return new ModelRegistryTableRow(() => + this.findTable().find(`[data-label="Model name"]`).contains(name).parents('tr'), + ); + } + + getModelVersionRow(name: string) { + return new ModelRegistryTableRow(() => + this.findModelVersionsTable() + .find(`[data-label="Version name"]`) + .contains(name) + .parents('tr'), + ); + } findRegisteredModelTableHeaderButton(name: string) { return this.findTable().find('thead').findByRole('button', { name }); @@ -189,4 +187,4 @@ class ModelRegistry { } export const modelRegistry = new ModelRegistry(); -// export const labelModal = new LabelModal(); +export const labelModal = new LabelModal(); 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 e96daa3c..730064da 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 @@ -2,6 +2,7 @@ import type { GenericStaticResponse, RouteHandlerController } from 'cypress/type import type { ModelArtifact, ModelArtifactList, + ModelRegistry, ModelRegistryResponse, ModelVersion, ModelVersionList, @@ -31,7 +32,7 @@ declare global { interceptApi: (( type: 'GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models', options: { path: { modelRegistryName: string; apiVersion: string } }, - response: ApiResponse, + response: ApiResponse>, ) => Cypress.Chainable) & (( type: 'POST /api/:apiVersion/model_registry/:modelRegistryName/registered_models', @@ -43,7 +44,7 @@ declare global { options: { path: { modelRegistryName: string; apiVersion: string; registeredModelId: number }; }, - response: ApiResponse, + response: ApiResponse>, ) => Cypress.Chainable) & (( type: 'POST /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId/versions', @@ -97,7 +98,7 @@ declare global { (( type: 'GET /api/:apiVersion/model_registry', options: { path: { apiVersion: string } }, - response: ApiResponse, + response: ApiResponse>, ) => Cypress.Chainable); } } diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry.cy.ts index ac6c0729..91592ae1 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry.cy.ts @@ -4,9 +4,10 @@ import { mockModelVersion } from '~/__mocks__/mockModelVersion'; import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; -import { modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; -import { mockModelRegistryResponse } from '~/__mocks__/mockModelRegistryResponse'; +import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; +import { mockBFFResponse } from '~/__mocks__/mockBFFResponse'; import type { ModelRegistry, ModelVersion, RegisteredModel } from '~/app/types'; +import { be } from '~/__tests__/cypress/cypress/utils/should'; const MODEL_REGISTRY_API_VERSION = 'v1'; @@ -69,7 +70,7 @@ const initIntercepts = ({ { path: { apiVersion: MODEL_REGISTRY_API_VERSION }, }, - mockModelRegistryResponse({ model_registry: modelRegistries }), + mockBFFResponse(modelRegistries), ); cy.interceptApi( @@ -77,7 +78,7 @@ const initIntercepts = ({ { path: { modelRegistryName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION }, }, - mockRegisteredModelList({ items: registeredModels }), + mockBFFResponse(mockRegisteredModelList({ items: registeredModels })), ); cy.interceptApi( @@ -89,13 +90,28 @@ const initIntercepts = ({ registeredModelId: 1, }, }, - mockModelVersionList({ items: modelVersions }), + mockBFFResponse(mockModelVersionList({ items: modelVersions })), ); }; describe('Model Registry core', () => { it('Model Registry Enabled in the cluster', () => { - initIntercepts({}); + initIntercepts({ + registeredModels: [ + mockRegisteredModel({ + name: 'Fraud detection model', + description: + 'A machine learning model trained to detect fraudulent transactions in financial data', + labels: [ + 'Financial data', + 'Fraud detection', + 'Test label', + 'Machine learning', + 'Next data to be overflow', + ], + }), + ], + }); modelRegistry.visit(); modelRegistry.navigate(); @@ -103,16 +119,15 @@ describe('Model Registry core', () => { modelRegistry.tabEnabled(); }); - // it('Renders empty state with no model registries', () => { - // initIntercepts({ - // disableModelRegistryFeature: false, - // modelRegistries: [], - // }); + it('Renders empty state with no model registries', () => { + initIntercepts({ + modelRegistries: [], + }); - // modelRegistry.visit(); - // modelRegistry.navigate(); - // modelRegistry.findModelRegistryEmptyState().should('exist'); - // }); + modelRegistry.visit(); + modelRegistry.navigate(); + modelRegistry.findModelRegistryEmptyState().should('exist'); + }); it('No registered models in the selected Model Registry', () => { initIntercepts({ @@ -122,74 +137,73 @@ describe('Model Registry core', () => { modelRegistry.visit(); modelRegistry.navigate(); modelRegistry.shouldModelRegistrySelectorExist(); - // modelRegistry.shouldregisteredModelsEmpty(); + modelRegistry.shouldregisteredModelsEmpty(); }); - // TODO: Enable when registered model table is enabled - // describe('Registered model table', () => { - // beforeEach(() => { - // initIntercepts({ disableModelRegistryFeature: false }); - // modelRegistry.visit(); - // }); - - // it('Renders row contents', () => { - // const registeredModelRow = modelRegistry.getRow('Fraud detection model'); - // registeredModelRow.findName().contains('Fraud detection model'); - // registeredModelRow - // .findDescription() - // .contains( - // 'A machine learning model trained to detect fraudulent transactions in financial data', - // ); - // registeredModelRow.findOwner().contains('Author 1'); - - // // Label popover - // registeredModelRow.findLabelPopoverText().contains('2 more'); - // registeredModelRow.findLabelPopoverText().click(); - // registeredModelRow.shouldContainsPopoverLabels([ - // 'Machine learning', - // 'Next data to be overflow', - // ]); - // }); - - // it('Renders labels in modal', () => { - // const registeredModelRow2 = modelRegistry.getRow('Label modal'); - // registeredModelRow2.findLabelModalText().contains('6 more'); - // registeredModelRow2.findLabelModalText().click(); - // labelModal.shouldContainsModalLabels([ - // 'Testing label', - // 'Financial', - // 'Financial data', - // 'Fraud detection', - // 'Machine learning', - // 'Next data to be overflow', - // 'Label x', - // 'Label y', - // 'Label z', - // ]); - // labelModal.findModalSearchInput().type('Financial'); - // labelModal.shouldContainsModalLabels(['Financial', 'Financial data']); - // labelModal.findCloseModal().click(); - // }); - - // it('Sort by Model name', () => { - // modelRegistry.findRegisteredModelTableHeaderButton('Model name').click(); - // modelRegistry.findRegisteredModelTableHeaderButton('Model name').should(be.sortAscending); - // modelRegistry.findRegisteredModelTableHeaderButton('Model name').click(); - // modelRegistry.findRegisteredModelTableHeaderButton('Model name').should(be.sortDescending); - // }); - - // it('Sort by Last modified', () => { - // modelRegistry.findRegisteredModelTableHeaderButton('Last modified').should(be.sortAscending); - // modelRegistry.findRegisteredModelTableHeaderButton('Last modified').click(); - // modelRegistry.findRegisteredModelTableHeaderButton('Last modified').should(be.sortDescending); - // }); - - // it('Filter by keyword', () => { - // modelRegistry.findTableSearch().type('Fraud detection model'); - // modelRegistry.findTableRows().should('have.length', 1); - // modelRegistry.findTableRows().contains('Fraud detection model'); - // }); - // }); + describe('Registered model table', () => { + beforeEach(() => { + initIntercepts({}); + modelRegistry.visit(); + }); + + it('Renders row contents', () => { + const registeredModelRow = modelRegistry.getRow('Fraud detection model'); + registeredModelRow.findName().contains('Fraud detection model'); + registeredModelRow + .findDescription() + .contains( + 'A machine learning model trained to detect fraudulent transactions in financial data', + ); + registeredModelRow.findOwner().contains('Author 1'); + + // Label popover + registeredModelRow.findLabelPopoverText().contains('2 more'); + registeredModelRow.findLabelPopoverText().click(); + registeredModelRow.shouldContainsPopoverLabels([ + 'Machine learning', + 'Next data to be overflow', + ]); + }); + + it('Renders labels in modal', () => { + const registeredModelRow2 = modelRegistry.getRow('Label modal'); + registeredModelRow2.findLabelModalText().contains('6 more'); + registeredModelRow2.findLabelModalText().click(); + labelModal.shouldContainsModalLabels([ + 'Testing label', + 'Financial', + 'Financial data', + 'Fraud detection', + 'Machine learning', + 'Next data to be overflow', + 'Label x', + 'Label y', + 'Label z', + ]); + labelModal.findModalSearchInput().type('Financial'); + labelModal.shouldContainsModalLabels(['Financial', 'Financial data']); + labelModal.findCloseModal().click(); + }); + + it('Sort by Model name', () => { + modelRegistry.findRegisteredModelTableHeaderButton('Model name').click(); + modelRegistry.findRegisteredModelTableHeaderButton('Model name').should(be.sortAscending); + modelRegistry.findRegisteredModelTableHeaderButton('Model name').click(); + modelRegistry.findRegisteredModelTableHeaderButton('Model name').should(be.sortDescending); + }); + + it('Sort by Last modified', () => { + modelRegistry.findRegisteredModelTableHeaderButton('Last modified').should(be.sortAscending); + modelRegistry.findRegisteredModelTableHeaderButton('Last modified').click(); + modelRegistry.findRegisteredModelTableHeaderButton('Last modified').should(be.sortDescending); + }); + + it('Filter by keyword', () => { + modelRegistry.findTableSearch().type('Fraud detection model'); + modelRegistry.findTableRows().should('have.length', 1); + modelRegistry.findTableRows().contains('Fraud detection model'); + }); + }); }); // TODO: Enable when model registration is there diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/utils/should.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/utils/should.ts new file mode 100644 index 00000000..d8b39801 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/utils/should.ts @@ -0,0 +1,56 @@ +const status = { + warning: ($subject: JQuery) => { + expect($subject.hasClass('pf-m-warning')).to.eq(true); + }, + info: ($subject: JQuery) => { + expect($subject.hasClass('pf-m-info')).to.eq(true); + }, + danger: ($subject: JQuery) => { + expect($subject.hasClass('pf-m-danger')).to.eq(true); + }, + success: ($subject: JQuery) => { + expect($subject.hasClass('pf-m-success')).to.eq(true); + }, + custom: ($subject: JQuery) => { + expect($subject.hasClass('pf-m-custom')).to.eq(true); + }, + error: ($subject: JQuery) => { + expect($subject.hasClass('pf-m-error')).to.eq(true); + }, + indeterminate: ($subject: JQuery) => { + expect($subject.hasClass('pf-m-indeterminate')).to.eq(true); + }, +}; + +const expandCollapse = { + expanded: ($subject: JQuery) => { + expect($subject.hasClass('pf-m-expanded')).to.eq(true); + }, + + collapsed: ($subject: JQuery) => { + expect($subject.hasClass('pf-m-expanded')).to.eq(false); + }, +}; + +const form = { + invalid: ($subject: JQuery) => { + expect($subject.attr('aria-invalid')).to.eq('true'); + }, +}; + +const sort = { + sortAscending: ($subject: JQuery) => { + expect($subject.parents('th').attr('aria-sort')).to.eq('ascending'); + }, + + sortDescending: ($subject: JQuery) => { + expect($subject.parents('th').attr('aria-sort')).to.eq('descending'); + }, +}; + +export const be = { + ...status, + ...expandCollapse, + ...form, + ...sort, +}; diff --git a/clients/ui/frontend/src/app/api/__tests__/errorUtils.spec.ts b/clients/ui/frontend/src/app/api/__tests__/errorUtils.spec.ts index fdf473f1..921a3d7b 100644 --- a/clients/ui/frontend/src/app/api/__tests__/errorUtils.spec.ts +++ b/clients/ui/frontend/src/app/api/__tests__/errorUtils.spec.ts @@ -2,12 +2,13 @@ import { NotReadyError } from '~/utilities/useFetchState'; import { APIError } from '~/app/api/types'; import { handleRestFailures } from '~/app/api/errorUtils'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { mockBFFResponse } from '~/__mocks__/utils'; describe('handleRestFailures', () => { it('should successfully return registered models', async () => { const modelRegistryMock = mockRegisteredModel({}); - const result = await handleRestFailures(Promise.resolve(modelRegistryMock)); - expect(result).toStrictEqual(modelRegistryMock); + const result = await handleRestFailures(Promise.resolve(mockBFFResponse(modelRegistryMock))); + expect(result.data).toStrictEqual(modelRegistryMock); }); it('should handle and throw model registry errors', async () => { diff --git a/clients/ui/frontend/src/app/api/__tests__/service.spec.ts b/clients/ui/frontend/src/app/api/__tests__/service.spec.ts index 6bfe6e6a..7da6df75 100644 --- a/clients/ui/frontend/src/app/api/__tests__/service.spec.ts +++ b/clients/ui/frontend/src/app/api/__tests__/service.spec.ts @@ -1,4 +1,4 @@ -import { restCREATE, restGET, restPATCH } from '~/app/api/apiUtils'; +import { isModelRegistryResponse, restCREATE, restGET, restPATCH } from '~/app/api/apiUtils'; import { handleRestFailures } from '~/app/api/errorUtils'; import { ModelState, ModelArtifactState } from '~/app/types'; import { @@ -21,436 +21,374 @@ import { } from '~/app/api/service'; import { BFF_API_VERSION } from '~/app/const'; -const mockProxyPromise = Promise.resolve(); +const mockRestPromise = Promise.resolve({ data: {} }); +const mockRestResponse = {}; jest.mock('~/app/api/apiUtils', () => ({ - restCREATE: jest.fn(() => mockProxyPromise), - restGET: jest.fn(() => mockProxyPromise), - restPATCH: jest.fn(() => mockProxyPromise), + restCREATE: jest.fn(() => mockRestPromise), + restGET: jest.fn(() => mockRestPromise), + restPATCH: jest.fn(() => mockRestPromise), + isModelRegistryResponse: jest.fn(() => true), })); -const mockResultPromise = Promise.resolve(); - jest.mock('~/app/api/errorUtils', () => ({ - handleRestFailures: jest.fn(() => mockResultPromise), + handleRestFailures: jest.fn(() => mockRestPromise), })); const handleRestFailuresMock = jest.mocked(handleRestFailures); const restCREATEMock = jest.mocked(restCREATE); const restGETMock = jest.mocked(restGET); const restPATCHMock = jest.mocked(restPATCH); +const isModelRegistryResponseMock = jest.mocked(isModelRegistryResponse); -const K8sAPIOptionsMock = {}; +const APIOptionsMock = {}; describe('createRegisteredModel', () => { - it('should call restCREATE and handleRestFailures to create registered model', () => { - expect( - createRegisteredModel(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - K8sAPIOptionsMock, - { - description: 'test', - externalID: '1', - name: 'test new registered model', - state: ModelState.LIVE, - customProperties: {}, - }, - ), - ).toBe(mockResultPromise); + it('should call restCREATE and handleRestFailures to create registered model', async () => { + const mockData = { + description: 'test', + externalID: '1', + name: 'test new registered model', + state: ModelState.LIVE, + customProperties: {}, + }; + const response = await createRegisteredModel( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, mockData); + expect(response).toEqual(mockRestResponse); expect(restCREATEMock).toHaveBeenCalledTimes(1); + expect(isModelRegistryResponseMock).toHaveBeenCalledTimes(1); expect(restCREATEMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/registered_models`, - { - description: 'test', - externalID: '1', - name: 'test new registered model', - state: ModelState.LIVE, - customProperties: {}, - }, + mockData, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('createModelVersion', () => { - it('should call restCREATE and handleRestFailures to create model version', () => { - expect( - createModelVersion(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - K8sAPIOptionsMock, - { - description: 'test', - externalID: '1', - author: 'test author', - registeredModelId: '1', - name: 'test new model version', - state: ModelState.LIVE, - customProperties: {}, - }, - ), - ).toBe(mockResultPromise); + it('should call restCREATE and handleRestFailures to create model version', async () => { + const mockData = { + description: 'test', + externalID: '1', + author: 'test author', + registeredModelId: '1', + name: 'test new model version', + state: ModelState.LIVE, + customProperties: {}, + }; + const response = await createModelVersion( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, mockData); + expect(response).toEqual(mockRestResponse); expect(restCREATEMock).toHaveBeenCalledTimes(1); expect(restCREATEMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_versions`, - { - description: 'test', - externalID: '1', - author: 'test author', - registeredModelId: '1', - name: 'test new model version', - state: ModelState.LIVE, - customProperties: {}, - }, + mockData, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('createModelVersionForRegisteredModel', () => { - it('should call restCREATE and handleRestFailures to create model version for a model', () => { - expect( - createModelVersionForRegisteredModel( - `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, - )(K8sAPIOptionsMock, '1', { - description: 'test', - externalID: '1', - author: 'test author', - registeredModelId: '1', - name: 'test new model version', - state: ModelState.LIVE, - customProperties: {}, - }), - ).toBe(mockResultPromise); + it('should call restCREATE and handleRestFailures to create model version for a model', async () => { + const mockData = { + description: 'test', + externalID: '1', + author: 'test author', + registeredModelId: '1', + name: 'test new model version', + state: ModelState.LIVE, + customProperties: {}, + }; + const response = await createModelVersionForRegisteredModel( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, '1', mockData); + expect(response).toEqual(mockRestResponse); expect(restCREATEMock).toHaveBeenCalledTimes(1); expect(restCREATEMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/registered_models/1/versions`, - { - description: 'test', - externalID: '1', - author: 'test author', - registeredModelId: '1', - name: 'test new model version', - state: ModelState.LIVE, - customProperties: {}, - }, + mockData, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('createModelArtifact', () => { - it('should call restCREATE and handleRestFailures to create model artifact', () => { - expect( - createModelArtifact(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - K8sAPIOptionsMock, - { - description: 'test', - externalID: 'test', - uri: 'test-uri', - state: ModelArtifactState.LIVE, - name: 'test-name', - modelFormatName: 'test-modelformatname', - storageKey: 'teststoragekey', - storagePath: 'teststoragePath', - modelFormatVersion: 'testmodelFormatVersion', - serviceAccountName: 'testserviceAccountname', - customProperties: {}, - artifactType: 'model-artifact', - }, - ), - ).toBe(mockResultPromise); + it('should call restCREATE and handleRestFailures to create model artifact', async () => { + const mockData = { + description: 'test', + externalID: 'test', + uri: 'test-uri', + state: ModelArtifactState.LIVE, + name: 'test-name', + modelFormatName: 'test-modelformatname', + storageKey: 'teststoragekey', + storagePath: 'teststoragePath', + modelFormatVersion: 'testmodelFormatVersion', + serviceAccountName: 'testserviceAccountname', + customProperties: {}, + artifactType: 'model-artifact', + }; + const response = await createModelArtifact( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, mockData); + expect(response).toEqual(mockRestResponse); expect(restCREATEMock).toHaveBeenCalledTimes(1); expect(restCREATEMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_artifacts`, - { - description: 'test', - externalID: 'test', - uri: 'test-uri', - state: ModelArtifactState.LIVE, - name: 'test-name', - modelFormatName: 'test-modelformatname', - storageKey: 'teststoragekey', - storagePath: 'teststoragePath', - modelFormatVersion: 'testmodelFormatVersion', - serviceAccountName: 'testserviceAccountname', - customProperties: {}, - artifactType: 'model-artifact', - }, + mockData, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('createModelArtifactForModelVersion', () => { - it('should call restCREATE and handleRestFailures to create model artifact for version', () => { - expect( - createModelArtifactForModelVersion( - `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, - )(K8sAPIOptionsMock, '2', { - description: 'test', - externalID: 'test', - uri: 'test-uri', - state: ModelArtifactState.LIVE, - name: 'test-name', - modelFormatName: 'test-modelformatname', - storageKey: 'teststoragekey', - storagePath: 'teststoragePath', - modelFormatVersion: 'testmodelFormatVersion', - serviceAccountName: 'testserviceAccountname', - customProperties: {}, - artifactType: 'model-artifact', - }), - ).toBe(mockResultPromise); + it('should call restCREATE and handleRestFailures to create model artifact for version', async () => { + const mockData = { + description: 'test', + externalID: 'test', + uri: 'test-uri', + state: ModelArtifactState.LIVE, + name: 'test-name', + modelFormatName: 'test-modelformatname', + storageKey: 'teststoragekey', + storagePath: 'teststoragePath', + modelFormatVersion: 'testmodelFormatVersion', + serviceAccountName: 'testserviceAccountname', + customProperties: {}, + artifactType: 'model-artifact', + }; + const response = await createModelArtifactForModelVersion( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, '2', mockData); + expect(response).toEqual(mockRestResponse); expect(restCREATEMock).toHaveBeenCalledTimes(1); expect(restCREATEMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_versions/2/artifacts`, - { - description: 'test', - externalID: 'test', - uri: 'test-uri', - state: ModelArtifactState.LIVE, - name: 'test-name', - modelFormatName: 'test-modelformatname', - storageKey: 'teststoragekey', - storagePath: 'teststoragePath', - modelFormatVersion: 'testmodelFormatVersion', - serviceAccountName: 'testserviceAccountname', - customProperties: {}, - artifactType: 'model-artifact', - }, + mockData, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('getRegisteredModel', () => { - it('should call restGET and handleRestFailures to fetch registered model', () => { - expect( - getRegisteredModel(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - K8sAPIOptionsMock, - '1', - ), - ).toBe(mockResultPromise); + it('should call restGET and handleRestFailures to fetch registered model', async () => { + const response = await getRegisteredModel( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, '1'); + expect(response).toEqual(mockRestResponse); expect(restGETMock).toHaveBeenCalledTimes(1); expect(restGETMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/registered_models/1`, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('getModelVersion', () => { - it('should call restGET and handleRestFailures to fetch model version', () => { - expect( - getModelVersion(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - K8sAPIOptionsMock, - '1', - ), - ).toBe(mockResultPromise); + it('should call restGET and handleRestFailures to fetch model version', async () => { + const response = await getModelVersion( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, '1'); + expect(response).toEqual(mockRestResponse); expect(restGETMock).toHaveBeenCalledTimes(1); expect(restGETMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_versions/1`, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('getModelArtifact', () => { - it('should call restGET and handleRestFailures to fetch model version', () => { - expect( - getModelArtifact(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - K8sAPIOptionsMock, - '1', - ), - ).toBe(mockResultPromise); + it('should call restGET and handleRestFailures to fetch model version', async () => { + const response = await getModelArtifact( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, '1'); + expect(response).toEqual(mockRestResponse); expect(restGETMock).toHaveBeenCalledTimes(1); expect(restGETMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_artifacts/1`, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('getListRegisteredModels', () => { - it('should call restGET and handleRestFailures to list registered models', () => { - expect( - getListRegisteredModels(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)({}), - ).toBe(mockResultPromise); + it('should call restGET and handleRestFailures to list registered models', async () => { + const response = await getListRegisteredModels( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )({}); + expect(response).toEqual(mockRestResponse); expect(restGETMock).toHaveBeenCalledTimes(1); expect(restGETMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/registered_models`, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('getListModelArtifacts', () => { - it('should call restGET and handleRestFailures to list models artifacts', () => { - expect( - getListModelArtifacts(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)({}), - ).toBe(mockResultPromise); + it('should call restGET and handleRestFailures to list models artifacts', async () => { + const response = await getListModelArtifacts( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )({}); + expect(response).toEqual(mockRestResponse); expect(restGETMock).toHaveBeenCalledTimes(1); expect(restGETMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_artifacts`, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('getListModelVersions', () => { - it('should call restGET and handleRestFailures to list models versions', () => { - expect( - getListModelVersions(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)({}), - ).toBe(mockResultPromise); + it('should call restGET and handleRestFailures to list models versions', async () => { + const response = await getListModelVersions( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )({}); + expect(response).toEqual(mockRestResponse); expect(restGETMock).toHaveBeenCalledTimes(1); expect(restGETMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_versions`, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('getModelVersionsByRegisteredModel', () => { - it('should call restGET and handleRestFailures to list models versions by registered model', () => { - expect( - getModelVersionsByRegisteredModel(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - {}, - '1', - ), - ).toBe(mockResultPromise); + it('should call restGET and handleRestFailures to list models versions by registered model', async () => { + const response = await getModelVersionsByRegisteredModel( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )({}, '1'); + expect(response).toEqual(mockRestResponse); expect(restGETMock).toHaveBeenCalledTimes(1); expect(restGETMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/registered_models/1/versions`, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('getModelArtifactsByModelVersion', () => { - it('should call restGET and handleRestFailures to list models artifacts by model version', () => { - expect( - getModelArtifactsByModelVersion(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - {}, - '1', - ), - ).toBe(mockResultPromise); + it('should call restGET and handleRestFailures to list models artifacts by model version', async () => { + const response = await getModelArtifactsByModelVersion( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )({}, '1'); + expect(response).toEqual(mockRestResponse); expect(restGETMock).toHaveBeenCalledTimes(1); expect(restGETMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_versions/1/artifacts`, {}, - K8sAPIOptionsMock, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('patchRegisteredModel', () => { - it('should call restPATCH and handleRestFailures to update registered model', () => { - expect( - patchRegisteredModel(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - K8sAPIOptionsMock, - { description: 'new test' }, - '1', - ), - ).toBe(mockResultPromise); + it('should call restPATCH and handleRestFailures to update registered model', async () => { + const mockData = { description: 'new test' }; + const response = await patchRegisteredModel( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, mockData, '1'); + expect(response).toEqual(mockRestResponse); expect(restPATCHMock).toHaveBeenCalledTimes(1); expect(restPATCHMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/registered_models/1`, - { description: 'new test' }, - K8sAPIOptionsMock, + mockData, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('patchModelVersion', () => { - it('should call restPATCH and handleRestFailures to update model version', () => { - expect( - patchModelVersion(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - K8sAPIOptionsMock, - { description: 'new test' }, - '1', - ), - ).toBe(mockResultPromise); + it('should call restPATCH and handleRestFailures to update model version', async () => { + const mockData = { description: 'new test' }; + const response = await patchModelVersion( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, mockData, '1'); + expect(response).toEqual(mockRestResponse); expect(restPATCHMock).toHaveBeenCalledTimes(1); expect(restPATCHMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_versions/1`, - { description: 'new test' }, - K8sAPIOptionsMock, + mockData, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); describe('patchModelArtifact', () => { - it('should call restPATCH and handleRestFailures to update model artifact', () => { - expect( - patchModelArtifact(`/api/${BFF_API_VERSION}/model_registry/model-registry-1/`)( - K8sAPIOptionsMock, - { description: 'new test' }, - '1', - ), - ).toBe(mockResultPromise); + it('should call restPATCH and handleRestFailures to update model artifact', async () => { + const mockData = { description: 'new test' }; + const response = await patchModelArtifact( + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, + )(APIOptionsMock, mockData, '1'); + expect(response).toEqual(mockRestResponse); expect(restPATCHMock).toHaveBeenCalledTimes(1); expect(restPATCHMock).toHaveBeenCalledWith( `/api/${BFF_API_VERSION}/model_registry/model-registry-1/`, `/model_artifacts/1`, - { description: 'new test' }, - K8sAPIOptionsMock, + mockData, + APIOptionsMock, ); expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); - expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockRestPromise); }); }); diff --git a/clients/ui/frontend/src/app/api/apiUtils.ts b/clients/ui/frontend/src/app/api/apiUtils.ts index 0af63847..93e16681 100644 --- a/clients/ui/frontend/src/app/api/apiUtils.ts +++ b/clients/ui/frontend/src/app/api/apiUtils.ts @@ -163,11 +163,14 @@ export const restDELETE = ( parseJSON: options?.parseJSON, }); -export const isModelRegistryResponse = (response: unknown): response is ModelRegistryResponse => { +export const isModelRegistryResponse = ( + response: unknown, +): response is ModelRegistryResponse => { if (typeof response === 'object' && response !== null) { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const modelRegistryResponse = response as { model_registry?: unknown }; - return Array.isArray(modelRegistryResponse.model_registry); + const modelRegistryResponse = response as { data?: T }; + // TODO: Check if data is conforming any type so we have a proper check + return modelRegistryResponse.data !== undefined; } return false; }; diff --git a/clients/ui/frontend/src/app/api/k8s.ts b/clients/ui/frontend/src/app/api/k8s.ts index 07f70e98..0b97eb89 100644 --- a/clients/ui/frontend/src/app/api/k8s.ts +++ b/clients/ui/frontend/src/app/api/k8s.ts @@ -9,8 +9,8 @@ export const getListModelRegistries = (opts: APIOptions): Promise => handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/model_registry`, {}, opts)).then( (response) => { - if (isModelRegistryResponse(response)) { - return response.model_registry; + if (isModelRegistryResponse(response)) { + return response.data; } throw new Error('Invalid response format'); }, diff --git a/clients/ui/frontend/src/app/api/service.ts b/clients/ui/frontend/src/app/api/service.ts index 696c46ba..b894c6df 100644 --- a/clients/ui/frontend/src/app/api/service.ts +++ b/clients/ui/frontend/src/app/api/service.ts @@ -9,19 +9,31 @@ import { RegisteredModelList, RegisteredModel, } from '~/app/types'; -import { restCREATE, restGET, restPATCH } from '~/app/api/apiUtils'; +import { isModelRegistryResponse, restCREATE, restGET, restPATCH } from '~/app/api/apiUtils'; import { APIOptions } from '~/app/api/types'; import { handleRestFailures } from '~/app/api/errorUtils'; export const createRegisteredModel = (hostPath: string) => (opts: APIOptions, data: CreateRegisteredModelData): Promise => - handleRestFailures(restCREATE(hostPath, `/registered_models`, data, {}, opts)); + handleRestFailures(restCREATE(hostPath, `/registered_models`, data, {}, opts)).then( + (response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); export const createModelVersion = (hostPath: string) => (opts: APIOptions, data: CreateModelVersionData): Promise => - handleRestFailures(restCREATE(hostPath, `/model_versions`, data, {}, opts)); + handleRestFailures(restCREATE(hostPath, `/model_versions`, data, {}, opts)).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const createModelVersionForRegisteredModel = (hostPath: string) => @@ -32,12 +44,24 @@ export const createModelVersionForRegisteredModel = ): Promise => handleRestFailures( restCREATE(hostPath, `/registered_models/${registeredModelId}/versions`, data, {}, opts), - ); + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const createModelArtifact = (hostPath: string) => (opts: APIOptions, data: CreateModelArtifactData): Promise => - handleRestFailures(restCREATE(hostPath, `/model_artifacts`, data, {}, opts)); + handleRestFailures(restCREATE(hostPath, `/model_artifacts`, data, {}, opts)).then( + (response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); export const createModelArtifactForModelVersion = (hostPath: string) => @@ -48,49 +72,102 @@ export const createModelArtifactForModelVersion = ): Promise => handleRestFailures( restCREATE(hostPath, `/model_versions/${modelVersionId}/artifacts`, data, {}, opts), - ); + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const getRegisteredModel = (hostPath: string) => (opts: APIOptions, registeredModelId: string): Promise => - handleRestFailures(restGET(hostPath, `/registered_models/${registeredModelId}`, {}, opts)); + handleRestFailures(restGET(hostPath, `/registered_models/${registeredModelId}`, {}, opts)).then( + (response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); export const getModelVersion = (hostPath: string) => (opts: APIOptions, modelversionId: string): Promise => - handleRestFailures(restGET(hostPath, `/model_versions/${modelversionId}`, {}, opts)); + handleRestFailures(restGET(hostPath, `/model_versions/${modelversionId}`, {}, opts)).then( + (response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); export const getModelArtifact = (hostPath: string) => (opts: APIOptions, modelArtifactId: string): Promise => - handleRestFailures(restGET(hostPath, `/model_artifacts/${modelArtifactId}`, {}, opts)); + handleRestFailures(restGET(hostPath, `/model_artifacts/${modelArtifactId}`, {}, opts)).then( + (response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); export const getListModelArtifacts = (hostPath: string) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/model_artifacts`, {}, opts)); + handleRestFailures(restGET(hostPath, `/model_artifacts`, {}, opts)).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const getListModelVersions = (hostPath: string) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/model_versions`, {}, opts)); + handleRestFailures(restGET(hostPath, `/model_versions`, {}, opts)).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const getListRegisteredModels = (hostPath: string) => (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/registered_models`, {}, opts)); + handleRestFailures(restGET(hostPath, `/registered_models`, {}, opts)).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const getModelVersionsByRegisteredModel = (hostPath: string) => (opts: APIOptions, registeredmodelId: string): Promise => handleRestFailures( restGET(hostPath, `/registered_models/${registeredmodelId}/versions`, {}, opts), - ); + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const getModelArtifactsByModelVersion = (hostPath: string) => (opts: APIOptions, modelVersionId: string): Promise => - handleRestFailures(restGET(hostPath, `/model_versions/${modelVersionId}/artifacts`, {}, opts)); + handleRestFailures( + restGET(hostPath, `/model_versions/${modelVersionId}/artifacts`, {}, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const patchRegisteredModel = (hostPath: string) => @@ -99,12 +176,26 @@ export const patchRegisteredModel = data: Partial, registeredModelId: string, ): Promise => - handleRestFailures(restPATCH(hostPath, `/registered_models/${registeredModelId}`, data, opts)); + handleRestFailures( + restPATCH(hostPath, `/registered_models/${registeredModelId}`, data, opts), + ).then((response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }); export const patchModelVersion = (hostPath: string) => (opts: APIOptions, data: Partial, modelversionId: string): Promise => - handleRestFailures(restPATCH(hostPath, `/model_versions/${modelversionId}`, data, opts)); + handleRestFailures(restPATCH(hostPath, `/model_versions/${modelversionId}`, data, opts)).then( + (response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); export const patchModelArtifact = (hostPath: string) => @@ -113,4 +204,11 @@ export const patchModelArtifact = data: Partial, modelartifactId: string, ): Promise => - handleRestFailures(restPATCH(hostPath, `/model_artifacts/${modelartifactId}`, data, opts)); + handleRestFailures(restPATCH(hostPath, `/model_artifacts/${modelartifactId}`, data, opts)).then( + (response) => { + if (isModelRegistryResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); diff --git a/clients/ui/frontend/src/app/components/DashboardEmptyTableView.tsx b/clients/ui/frontend/src/app/components/DashboardEmptyTableView.tsx new file mode 100644 index 00000000..0ce3f8bf --- /dev/null +++ b/clients/ui/frontend/src/app/components/DashboardEmptyTableView.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { + Bullseye, + Button, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateVariant, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; + +type DashboardEmptyTableViewProps = { + hasIcon?: boolean; + onClearFilters: (event: React.SyntheticEvent) => void; + variant?: EmptyStateVariant; +}; + +const DashboardEmptyTableView: React.FC = ({ + onClearFilters, + hasIcon = true, + variant, +}) => ( + + + Adjust your filters and try again. + + + + + +); + +export default DashboardEmptyTableView; diff --git a/clients/ui/frontend/src/app/components/DashboardModalFooter.tsx b/clients/ui/frontend/src/app/components/DashboardModalFooter.tsx new file mode 100644 index 00000000..4b8d9944 --- /dev/null +++ b/clients/ui/frontend/src/app/components/DashboardModalFooter.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { + ActionList, + ActionListItem, + Alert, + Button, + ButtonProps, + Stack, + StackItem, +} from '@patternfly/react-core'; + +type DashboardModalFooterProps = { + submitLabel: string; + submitButtonVariant?: ButtonProps['variant']; + onSubmit: () => void; + onCancel: () => void; + isSubmitDisabled: boolean; + isSubmitLoading?: boolean; + isCancelDisabled?: boolean; + alertTitle: string; + error?: Error; +}; + +const DashboardModalFooter: React.FC = ({ + submitLabel, + submitButtonVariant = 'primary', + onSubmit, + onCancel, + isSubmitDisabled, + isSubmitLoading, + isCancelDisabled, + error, + alertTitle, +}) => ( + // make sure alert uses the full width + + {error && ( + + + {error.message} + + + )} + + + + + + + + + + + +); + +export default DashboardModalFooter; diff --git a/clients/ui/frontend/src/app/components/DashboardSearchField.tsx b/clients/ui/frontend/src/app/components/DashboardSearchField.tsx new file mode 100644 index 00000000..2aed1464 --- /dev/null +++ b/clients/ui/frontend/src/app/components/DashboardSearchField.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { InputGroup, SearchInput, InputGroupItem } from '@patternfly/react-core'; +import SimpleSelect from '~/app/components/SimpleSelect'; +import { asEnumMember } from '~/app/utils'; + +// List all the possible search fields here +export enum SearchType { + NAME = 'Name', + DESCRIPTION = 'Description', + USER = 'User', + PROJECT = 'Project', + METRIC = 'Metric', + PROTECTED_ATTRIBUTE = 'Protected attribute', + PRIVILEGED_VALUE = 'Privileged value', + UNPRIVILEGED_VALUE = 'Unprivileged value', + OUTPUT = 'Output', + OUTPUT_VALUE = 'Output value', + PROVIDER = 'Provider', + IDENTIFIER = 'Identifier', + KEYWORD = 'Keyword', + AUTHOR = 'Author', + OWNER = 'Owner', +} + +type DashboardSearchFieldProps = { + types: SearchType[]; + searchType: SearchType; + onSearchTypeChange: (searchType: SearchType) => void; + searchValue: string; + onSearchValueChange: (searchValue: string) => void; + icon?: React.ReactNode; +}; + +const DashboardSearchField: React.FC = ({ + types, + searchValue, + searchType, + onSearchValueChange, + onSearchTypeChange, + icon, +}) => ( + + + ({ + key, + label: key, + }))} + value={searchType} + onChange={(key) => { + const enumMember = asEnumMember(key, SearchType); + if (enumMember !== null) { + onSearchTypeChange(enumMember); + } + }} + icon={icon} + /> + + + { + onSearchValueChange(newSearch); + }} + onClear={() => onSearchValueChange('')} + style={{ minWidth: '200px' }} + /> + + +); + +export default DashboardSearchField; diff --git a/clients/ui/frontend/src/app/components/MarkdownView.scss b/clients/ui/frontend/src/app/components/MarkdownView.scss new file mode 100644 index 00000000..aa367b23 --- /dev/null +++ b/clients/ui/frontend/src/app/components/MarkdownView.scss @@ -0,0 +1,88 @@ +.kubeflow-markdown-view { + word-break: break-word; + + &--with-padding { + padding-bottom: var(--pf-v5-global--spacer--md); + + p { + margin-bottom: var(--pf-v5-global--spacer--sm); + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: var(--pf-v5-global--FontFamily--heading--sans-serif); + font-weight: var(--pf-v5-global--FontWeight--normal); + margin-top: var(--pf-v5-global--spacer--md); + margin-bottom: var(--pf-v5-global--spacer--sm); + } + + h1 { + font-size: var(--pf-v5-global--FontSize--2xl); + } + + h2 { + font-size: var(--pf-v5-global--FontSize--xl); + } + + h3 { + font-size: var(--pf-v5-global--FontSize--lg); + } + + h4, + h5, + h6 { + font-size: var(--pf-v5-global--FontSize--md); + margin-top: var(--pf-v5-global--spacer--sm); + } + + ul, + ol { + margin-top: 0; + margin-bottom: var(--pf-v5-global--spacer--sm); + } + + ul { + list-style: initial; + } + + li { + margin-left: var(--pf-v5-global--spacer--lg); + } + + code, + pre { + font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; + background-color: var(--pf-v5-global--BackgroundColor--200); + border-radius: var(--pf-v5-global--BorderRadius--sm); + } + + code { + padding: 2px 4px; + font-size: 85%; + color: var(--pf-v5-global--danger-color--100); + } + + pre { + display: block; + padding: var(--pf-v5-global--spacer--sm); + margin-bottom: var(--pf-v5-global--spacer--sm); + font-size: var(--pf-v5-global--FontSize--sm); + color: var(--pf-v5-global--Color--300); + word-break: break-all; + word-wrap: break-word; + background-color: var(--pf-v5-global--BackgroundColor--200); + border: var(--pf-v5-global--BorderWidth--sm) solid var(--pf-v5-global--Color--light-300); + border-radius: var(--pf-v5-global--BorderRadius--sm); + code { + padding: 0; + font-size: inherit; + color: inherit; + white-space: pre-wrap; + } + } +} diff --git a/clients/ui/frontend/src/app/components/MarkdownView.tsx b/clients/ui/frontend/src/app/components/MarkdownView.tsx new file mode 100644 index 00000000..4ece1204 --- /dev/null +++ b/clients/ui/frontend/src/app/components/MarkdownView.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import classNames from 'classnames'; +import { markdownConverter } from '~/utilities/markdown'; + +import './MarkdownView.scss'; + +type MarkdownViewProps = { + markdown?: string; + className?: string; + /** Strips some padding out so the content can fit as an inline-block effort */ + conciseDisplay?: boolean; + component?: 'div' | 'span'; +}; + +const MarkdownView: React.FC> = ({ + className = '', + markdown = '', + conciseDisplay, + component = 'div', + ...props +}) => { + const Component = component; + return ( + + ); +}; + +export default MarkdownView; diff --git a/clients/ui/frontend/src/app/components/SimpleSelect.scss b/clients/ui/frontend/src/app/components/SimpleSelect.scss new file mode 100644 index 00000000..30dd3051 --- /dev/null +++ b/clients/ui/frontend/src/app/components/SimpleSelect.scss @@ -0,0 +1,9 @@ +.full-width { + width: 100%; +} + +// remove this file when https://github.com/patternfly/patternfly/issues/6062 is solved +.truncate-no-min-width { + --pf-v5-c-truncate--MinWidth: 0; + --pf-v5-c-truncate__start--MinWidth: 0; +} diff --git a/clients/ui/frontend/src/app/components/SimpleSelect.tsx b/clients/ui/frontend/src/app/components/SimpleSelect.tsx new file mode 100644 index 00000000..27960895 --- /dev/null +++ b/clients/ui/frontend/src/app/components/SimpleSelect.tsx @@ -0,0 +1,144 @@ +import * as React from 'react'; +import { + Truncate, + MenuToggle, + Select, + SelectList, + SelectOption, + SelectGroup, + Divider, + MenuToggleProps, +} from '@patternfly/react-core'; + +import './SimpleSelect.scss'; + +export type SimpleSelectOption = { + key: string; + label: string; + description?: React.ReactNode; + dropdownLabel?: React.ReactNode; + isPlaceholder?: boolean; + isDisabled?: boolean; +}; + +export type SimpleGroupSelectOption = { + key: string; + label: string; + options: SimpleSelectOption[]; +}; + +type SimpleSelectProps = { + options?: SimpleSelectOption[]; + groupedOptions?: SimpleGroupSelectOption[]; + value?: string; + toggleLabel?: React.ReactNode; + placeholder?: string; + onChange: (key: string, isPlaceholder: boolean) => void; + isFullWidth?: boolean; + toggleProps?: MenuToggleProps; + isDisabled?: boolean; + icon?: React.ReactNode; + dataTestId?: string; +} & Omit< + React.ComponentProps, + 'isOpen' | 'toggle' | 'dropdownItems' | 'onChange' | 'selected' +>; + +const SimpleSelect: React.FC = ({ + isDisabled, + onChange, + options, + groupedOptions, + placeholder = 'Select...', + value, + toggleLabel, + isFullWidth, + icon, + dataTestId, + toggleProps, + ...props +}) => { + const [open, setOpen] = React.useState(false); + + const findOptionForKey = (key: string) => + options?.find((option) => option.key === key) || + groupedOptions + ?.reduce((acc, group) => [...acc, ...group.options], []) + .find((o) => o.key === key); + + const selectedOption = value ? findOptionForKey(value) : undefined; + const selectedLabel = selectedOption?.label ?? placeholder; + + return ( + + ); +}; + +export default SimpleSelect; diff --git a/clients/ui/frontend/src/app/components/design/utils.ts b/clients/ui/frontend/src/app/components/design/utils.ts index ef4f4114..23b67af9 100644 --- a/clients/ui/frontend/src/app/components/design/utils.ts +++ b/clients/ui/frontend/src/app/components/design/utils.ts @@ -1,5 +1,8 @@ import registerModelImg from '~/images/UI_icon-Cubes-RGB.svg'; import modelRegistryEmptyStateImg from '~/images/empty-state-model-registries.svg'; +import modelRegistryMissingModelImg from '~/images/no-models-model-registry.svg'; +import modelRegistryMissingVersionImg from '~/images/no-versions-model-registry.svg'; + import './vars.scss'; export enum ProjectObjectType { @@ -24,10 +27,17 @@ export const typedObjectImage = (objectType: ProjectObjectType): string => { } }; -export const typedEmptyImage = (objectType: ProjectObjectType): string => { +export const typedEmptyImage = (objectType: ProjectObjectType, option?: string): string => { switch (objectType) { case ProjectObjectType.registeredModels: - return modelRegistryEmptyStateImg; + switch (option) { + case 'MissingModel': + return modelRegistryMissingModelImg; + case 'MissingVersion': + return modelRegistryMissingVersionImg; + default: + return modelRegistryEmptyStateImg; + } default: return ''; } diff --git a/clients/ui/frontend/src/app/components/table/CheckboxTd.tsx b/clients/ui/frontend/src/app/components/table/CheckboxTd.tsx new file mode 100644 index 00000000..b8d12b77 --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/CheckboxTd.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { Td } from '@patternfly/react-table'; +import { Checkbox, Tooltip } from '@patternfly/react-core'; + +type CheckboxTrProps = { + id: string; + isChecked: boolean | null; + onToggle: () => void; + isDisabled?: boolean; + tooltip?: string; +} & React.ComponentProps; + +const CheckboxTd: React.FC = ({ + id, + isChecked, + onToggle, + isDisabled, + tooltip, + ...props +}) => { + let content = ( + onToggle()} + isDisabled={isDisabled} + /> + ); + + if (tooltip) { + content = {content}; + } + + return ( + + {content} + + ); +}; + +export default CheckboxTd; diff --git a/clients/ui/frontend/src/app/components/table/Table.tsx b/clients/ui/frontend/src/app/components/table/Table.tsx new file mode 100644 index 00000000..f702ff39 --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/Table.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { TbodyProps } from '@patternfly/react-table'; +import { EitherNotBoth } from '~/typeHelpers'; +import TableBase, { MIN_PAGE_SIZE } from './TableBase'; +import useTableColumnSort from './useTableColumnSort'; + +type TableProps = Omit< + React.ComponentProps>, + 'itemCount' | 'onPerPageSelect' | 'onSetPage' | 'page' | 'perPage' +> & + EitherNotBoth< + { disableRowRenderSupport?: boolean }, + { tbodyProps?: TbodyProps & { ref?: React.Ref } } + >; + +const Table = ({ + data: allData, + columns, + subColumns, + enablePagination, + defaultSortColumn = 0, + truncateRenderingAt = 0, + ...props +}: TableProps): React.ReactElement => { + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(MIN_PAGE_SIZE); + const sort = useTableColumnSort(columns, subColumns || [], defaultSortColumn); + const sortedData = sort.transformData(allData); + + let data: T[]; + if (truncateRenderingAt) { + data = sortedData.slice(0, truncateRenderingAt); + } else if (enablePagination) { + data = sortedData.slice(pageSize * (page - 1), pageSize * page); + } else { + data = sortedData; + } + + // update page to 1 if data changes (common when filter is applied) + React.useEffect(() => { + if (data.length === 0) { + setPage(1); + } + }, [data.length]); + + return ( + setPage(newPage)} + onPerPageSelect={(e, newSize, newPage) => { + setPageSize(newSize); + setPage(newPage); + }} + getColumnSort={sort.getColumnSort} + {...props} + /> + ); +}; + +export default Table; diff --git a/clients/ui/frontend/src/app/components/table/TableBase.tsx b/clients/ui/frontend/src/app/components/table/TableBase.tsx new file mode 100644 index 00000000..30f845fc --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/TableBase.tsx @@ -0,0 +1,318 @@ +import * as React from 'react'; +import { + Pagination, + PaginationProps, + Skeleton, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + Tooltip, +} from '@patternfly/react-core'; +import { + Table, + Thead, + Tr, + Th, + TableProps, + Caption, + Tbody, + Td, + TbodyProps, + InnerScrollContainer, +} from '@patternfly/react-table'; +import { EitherNotBoth } from '~/typeHelpers'; +import { GetColumnSort, SortableData } from './types'; +import { CHECKBOX_FIELD_ID, EXPAND_FIELD_ID, KEBAB_FIELD_ID } from './const'; + +type Props = { + loading?: boolean; + data: DataType[]; + columns: SortableData[]; + subColumns?: SortableData[]; + hasNestedHeader?: boolean; + defaultSortColumn?: number; + rowRenderer: (data: DataType, rowIndex: number) => React.ReactNode; + enablePagination?: boolean | 'compact'; + truncateRenderingAt?: number; + toolbarContent?: React.ReactElement; + bottomToolbarContent?: React.ReactElement; + emptyTableView?: React.ReactNode; + caption?: string; + footerRow?: (pageNumber: number) => React.ReactElement | null; + selectAll?: { + onSelect: (value: boolean) => void; + selected: boolean; + disabled?: boolean; + tooltip?: string; + }; + getColumnSort?: GetColumnSort; + disableItemCount?: boolean; + hasStickyColumns?: boolean; +} & EitherNotBoth< + { disableRowRenderSupport?: boolean }, + { tbodyProps?: TbodyProps & { ref?: React.Ref } } +> & + Omit & + Pick< + PaginationProps, + | 'itemCount' + | 'onPerPageSelect' + | 'onSetPage' + | 'page' + | 'perPage' + | 'perPageOptions' + | 'toggleTemplate' + | 'onNextClick' + | 'onPreviousClick' + >; + +export const MIN_PAGE_SIZE = 10; + +const defaultPerPageOptions = [ + { + title: '10', + value: 10, + }, + { + title: '20', + value: 20, + }, + { + title: '30', + value: 30, + }, +]; + +const TableBase = ({ + data, + columns, + subColumns, + hasNestedHeader, + rowRenderer, + enablePagination, + toolbarContent, + bottomToolbarContent, + emptyTableView, + caption, + disableRowRenderSupport, + selectAll, + footerRow, + tbodyProps, + perPage = 10, + page = 1, + perPageOptions = defaultPerPageOptions, + onSetPage, + onNextClick, + onPreviousClick, + onPerPageSelect, + getColumnSort, + itemCount = 0, + loading, + toggleTemplate, + disableItemCount = false, + hasStickyColumns, + ...props +}: Props): React.ReactElement => { + const selectAllRef = React.useRef(null); + const showPagination = enablePagination; + + const pagination = (variant: 'top' | 'bottom') => ( + + ); + + // Use a reference to store the heights of table rows once loaded + const tableRef = React.useRef(null); + const rowHeightsRef = React.useRef(); + React.useLayoutEffect(() => { + if (!loading || rowHeightsRef.current == null) { + const heights: number[] = []; + const rows = tableRef.current?.querySelectorAll(':scope > tbody > tr'); + rows?.forEach((r) => heights.push(r.offsetHeight)); + rowHeightsRef.current = heights; + } + }, [loading]); + + const renderColumnHeader = (col: SortableData, i: number, isSubheader?: boolean) => { + if (col.field === CHECKBOX_FIELD_ID && selectAll) { + return ( + + + selectAll.onSelect(value), + isDisabled: selectAll.disabled, + }} + // TODO: Log PF bug -- when there are no rows this gets truncated + style={{ minWidth: '45px' }} + isSubheader={isSubheader} + aria-label="Select all" + /> + + ); + } + + return col.label ? ( + + {col.label} + + ) : ( + // Table headers cannot be empty for a11y, table cells can -- https://dequeuniversity.com/rules/axe/4.0/empty-table-header + + ); + }; + + const renderRows = () => + loading + ? // compute the number of items in the upcoming page + new Array( + itemCount === 0 + ? rowHeightsRef.current?.length || MIN_PAGE_SIZE + : Math.max(0, Math.min(perPage, itemCount - perPage * (page - 1))), + ) + .fill(undefined) + .map((_, i) => { + // Set the height to the last known row height or otherwise the same height as the first row. + // When going to a previous page, the number of rows may be greater than the current. + + const getRow = () => ( + + {columns.map((col) => ( + + { + // render placeholders to reserve space + col.field === EXPAND_FIELD_ID || col.field === KEBAB_FIELD_ID ? ( +
+ ) : col.field === CHECKBOX_FIELD_ID ? ( +
+ ) : ( + + ) + } + + ))} + + ); + return disableRowRenderSupport ? ( + {getRow()} + ) : ( + getRow() + ); + }) + : data.map((row, rowIndex) => rowRenderer(row, rowIndex)); + + const table = ( + + {caption && } + + {columns.map((col, i) => renderColumnHeader(col, i))} + {subColumns?.length ? ( + {subColumns.map((col, i) => renderColumnHeader(col, columns.length + i, true))} + ) : null} + + {disableRowRenderSupport ? renderRows() : {renderRows()}} + {footerRow && footerRow(page)} +
{caption}
+ ); + + return ( + <> + {(toolbarContent || showPagination) && ( + } + > + + {toolbarContent} + {showPagination && ( + + {pagination('top')} + + )} + + + )} + + {hasStickyColumns ? {table} : table} + + {!loading && emptyTableView && data.length === 0 && ( +
+ {emptyTableView} +
+ )} + + {(bottomToolbarContent || showPagination) && ( + + + {bottomToolbarContent} + {showPagination && ( + + {pagination('bottom')} + + )} + + + )} + + ); +}; + +export default TableBase; diff --git a/clients/ui/frontend/src/app/components/table/TableRowTitleDescription.tsx b/clients/ui/frontend/src/app/components/table/TableRowTitleDescription.tsx new file mode 100644 index 00000000..be2d02e9 --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/TableRowTitleDescription.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Content } from '@patternfly/react-core'; +import MarkdownView from '~/app/components/MarkdownView'; + +type TableRowTitleDescriptionProps = { + title: React.ReactNode; + // resource?: K8sResourceCommon; // TODO: Sort this out in refactor + subtitle?: React.ReactNode; + description?: string; + descriptionAsMarkdown?: boolean; + label?: React.ReactNode; +}; + +const TableRowTitleDescription: React.FC = ({ + title, + description, + // resource, + subtitle, + descriptionAsMarkdown, + label, +}) => { + let descriptionNode: React.ReactNode; + if (description) { + descriptionNode = descriptionAsMarkdown ? ( + + ) : ( + + {description} + + ); + } + + return ( + <> + + {/* {resource ? {title} : title} */} + {title} + + {subtitle} + {descriptionNode} + {label} + + ); +}; + +export default TableRowTitleDescription; diff --git a/clients/ui/frontend/src/app/components/table/__tests__/useCheckboxTable.spec.ts b/clients/ui/frontend/src/app/components/table/__tests__/useCheckboxTable.spec.ts new file mode 100644 index 00000000..8744706e --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/__tests__/useCheckboxTable.spec.ts @@ -0,0 +1,54 @@ +import { act } from 'react'; +import { testHook } from '~/__tests__/unit/testUtils/hooks'; +import { useCheckboxTable } from '~/app/components/table'; + +describe('useCheckboxTable', () => { + it('should select/unselect all', () => { + const renderResult = testHook(useCheckboxTable)(['a', 'b', 'c']); + + act(() => { + renderResult.result.current.tableProps.selectAll.onSelect(true); + }); + expect(renderResult.result.current.selections).toStrictEqual(['a', 'b', 'c']); + expect(renderResult.result.current.tableProps.selectAll.selected).toBe(true); + + act(() => { + renderResult.result.current.tableProps.selectAll.onSelect(false); + }); + expect(renderResult.result.current.selections).toStrictEqual([]); + expect(renderResult.result.current.tableProps.selectAll.selected).toBe(false); + }); + + it('should select/unselect ids', () => { + const renderResult = testHook(useCheckboxTable)(['a', 'b', 'c']); + + act(() => { + renderResult.result.current.toggleSelection('a'); + renderResult.result.current.toggleSelection('b'); + }); + expect(renderResult.result.current.selections).toStrictEqual(['a', 'b']); + expect(renderResult.result.current.isSelected('a')).toBe(true); + expect(renderResult.result.current.isSelected('b')).toBe(true); + expect(renderResult.result.current.isSelected('c')).toBe(false); + + act(() => { + renderResult.result.current.toggleSelection('a'); + }); + expect(renderResult.result.current.selections).toStrictEqual(['b']); + expect(renderResult.result.current.isSelected('a')).toBe(false); + expect(renderResult.result.current.isSelected('b')).toBe(true); + expect(renderResult.result.current.isSelected('c')).toBe(false); + }); + + it('should remove selected ids that no longer exist', () => { + const renderResult = testHook(useCheckboxTable)(['a', 'b', 'c']); + + act(() => { + renderResult.result.current.tableProps.selectAll.onSelect(true); + }); + + renderResult.rerender(['c']); + + expect(renderResult.result.current.selections).toStrictEqual(['c']); + }); +}); diff --git a/clients/ui/frontend/src/app/components/table/const.ts b/clients/ui/frontend/src/app/components/table/const.ts new file mode 100644 index 00000000..dda019bd --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/const.ts @@ -0,0 +1,23 @@ +import { SortableData } from './types'; + +export const CHECKBOX_FIELD_ID = 'checkbox'; +export const KEBAB_FIELD_ID = 'kebab'; +export const EXPAND_FIELD_ID = 'expand'; + +export const checkboxTableColumn = (): SortableData => ({ + label: '', + field: CHECKBOX_FIELD_ID, + sortable: false, +}); + +export const kebabTableColumn = (): SortableData => ({ + label: '', + field: KEBAB_FIELD_ID, + sortable: false, +}); + +export const expandTableColumn = (): SortableData => ({ + label: '', + field: EXPAND_FIELD_ID, + sortable: false, +}); diff --git a/clients/ui/frontend/src/app/components/table/index.ts b/clients/ui/frontend/src/app/components/table/index.ts new file mode 100644 index 00000000..e09c6641 --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/index.ts @@ -0,0 +1,13 @@ +export * from './types'; +export * from './const'; + +export { default as Table } from './Table'; +export { default as TableBase } from './TableBase'; +export { default as useCheckboxTable } from './useCheckboxTable'; +export { default as useCheckboxTableBase } from './useCheckboxTableBase'; +export type { UseCheckboxTableBaseProps } from './useCheckboxTableBase'; + +export { default as TableRowTitleDescription } from './TableRowTitleDescription'; +export { default as CheckboxTd } from './CheckboxTd'; + +export { getTableColumnSort } from './useTableColumnSort'; diff --git a/clients/ui/frontend/src/app/components/table/types.ts b/clients/ui/frontend/src/app/components/table/types.ts new file mode 100644 index 00000000..8e6bc155 --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/types.ts @@ -0,0 +1,27 @@ +import { ThProps } from '@patternfly/react-table'; + +export type GetColumnSort = (columnIndex: number) => ThProps['sort']; + +export type SortableData = Pick< + ThProps, + | 'hasRightBorder' + | 'isStickyColumn' + | 'stickyMinWidth' + | 'stickyLeftOffset' + | 'modifier' + | 'width' + | 'info' + | 'className' +> & { + label: string; + field: string; + colSpan?: number; + rowSpan?: number; + /** + * Set to false to disable sort. + * Set to true to handle string and number fields automatically (everything else is equal). + * Pass a function that will get the two results and what field needs to be matched. + * Assume ASC -- the result will be inverted internally if needed. + */ + sortable: boolean | ((a: T, b: T, keyField: string) => number); +}; diff --git a/clients/ui/frontend/src/app/components/table/useCheckboxTable.ts b/clients/ui/frontend/src/app/components/table/useCheckboxTable.ts new file mode 100644 index 00000000..f6680deb --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/useCheckboxTable.ts @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { useCheckboxTableBase } from '~/app/components/table'; +import type Table from './Table'; + +type UseCheckboxTable = { + selections: string[]; + tableProps: Required, 'selectAll'>>; + toggleSelection: (id: string) => void; + isSelected: (id: string) => boolean; + setSelections: (selections: string[]) => void; +}; + +const useCheckboxTable = ( + dataIds: string[], + defaultSelectedIds?: string[], + persistSelections?: boolean, +): UseCheckboxTable => { + const [selectedIds, setSelectedIds] = React.useState(defaultSelectedIds ?? []); + + return useCheckboxTableBase( + dataIds, + selectedIds, + setSelectedIds, + React.useCallback((d) => d, []), + { persistSelections }, + ); +}; + +export default useCheckboxTable; diff --git a/clients/ui/frontend/src/app/components/table/useCheckboxTableBase.ts b/clients/ui/frontend/src/app/components/table/useCheckboxTableBase.ts new file mode 100644 index 00000000..ad30914a --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/useCheckboxTableBase.ts @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { intersection, xor } from 'lodash-es'; +import type Table from './Table'; + +export type UseCheckboxTableBaseProps = { + selections: DataType[]; + tableProps: Required, 'selectAll'>>; + toggleSelection: (selection: DataType) => void; + isSelected: (selection: DataType) => boolean; + disableCheck: (item: DataType, enabled: boolean) => void; + setSelections: React.Dispatch>; +}; + +const useCheckboxTableBase = ( + data: T[], + selectedData: T[], + setSelectedData: React.Dispatch>, + dataMappingHelper: (selectData: T) => string, + options?: { selectAll?: { selected?: boolean; disabled?: boolean }; persistSelections?: boolean }, +): UseCheckboxTableBaseProps => { + const dataIds = React.useMemo(() => data.map(dataMappingHelper), [data, dataMappingHelper]); + + const [disabledData, setDisabledData] = React.useState([]); + + const selectedDataIds = React.useMemo( + () => selectedData.map(dataMappingHelper), + [selectedData, dataMappingHelper], + ); + + // remove selected ids that are no longer present in the provided dataIds + React.useEffect(() => { + if (options?.persistSelections) { + return; + } + + const newSelectedIds = intersection(selectedDataIds, dataIds); + const newSelectedData = newSelectedIds + .map((id) => data.find((d) => dataMappingHelper(d) === id)) + .filter((v): v is T => !!v); + if (selectedData.length !== newSelectedData.length) { + setSelectedData(newSelectedData); + } + }, [ + data, + dataIds, + dataMappingHelper, + options?.persistSelections, + selectedData, + selectedDataIds, + setSelectedData, + ]); + + const disableCheck = React.useCallback['disableCheck']>( + (item, disabled) => + setDisabledData((prevData) => + disabled + ? prevData.some((d) => dataMappingHelper(d) === dataMappingHelper(item)) + ? prevData + : [...prevData, item] + : prevData.filter((d) => dataMappingHelper(d) !== dataMappingHelper(item)), + ), + [dataMappingHelper], + ); + + return React.useMemo(() => { + // Header is selected if all selections and all ids are equal + // This will allow for checking of the header to "reset" to provided ids during a trim/filter + const checkable = data.filter( + (d) => !disabledData.some((item) => dataMappingHelper(item) === dataMappingHelper(d)), + ); + + const headerSelected = + selectedDataIds.length > 0 && + xor(selectedDataIds, checkable.map(dataMappingHelper)).length === 0; + + const allDisabled = selectedData.length === 0 && disabledData.length === data.length; + + return { + selections: selectedData, + setSelections: setSelectedData, + tableProps: { + selectAll: { + disabled: allDisabled, + tooltip: allDisabled ? 'No selectable rows' : undefined, + onSelect: (value) => { + setSelectedData(value ? checkable : []); + }, + selected: headerSelected, + ...options?.selectAll, + }, + }, + disableCheck, + isSelected: (selection) => selectedDataIds.includes(dataMappingHelper(selection)), + toggleSelection: (selection) => { + const id = dataMappingHelper(selection); + setSelectedData((prevData) => + prevData.map(dataMappingHelper).includes(id) + ? prevData.filter( + (currentSelectedData) => dataMappingHelper(currentSelectedData) !== id, + ) + : [...prevData, selection], + ); + }, + }; + }, [ + data, + selectedDataIds, + dataMappingHelper, + selectedData, + disabledData, + setSelectedData, + options?.selectAll, + disableCheck, + ]); +}; + +export default useCheckboxTableBase; diff --git a/clients/ui/frontend/src/app/components/table/useTableColumnSort.ts b/clients/ui/frontend/src/app/components/table/useTableColumnSort.ts new file mode 100644 index 00000000..56c4519d --- /dev/null +++ b/clients/ui/frontend/src/app/components/table/useTableColumnSort.ts @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { GetColumnSort, SortableData } from './types'; + +type TableColumnSortProps = { + columns: SortableData[]; + subColumns?: SortableData[]; + sortDirection?: 'asc' | 'desc'; + setSortDirection: (dir: 'asc' | 'desc') => void; +}; + +type TableColumnSortByFieldProps = TableColumnSortProps & { + sortField?: string; + setSortField: (field: string) => void; +}; + +type TableColumnSortByIndexProps = TableColumnSortProps & { + sortIndex?: number; + setSortIndex: (index: number) => void; +}; + +export const getTableColumnSort = ({ + columns, + subColumns, + sortField, + setSortField, + ...sortProps +}: TableColumnSortByFieldProps): GetColumnSort => + getTableColumnSortByIndex({ + columns, + subColumns, + sortIndex: columns.findIndex((c) => c.field === sortField), + setSortIndex: (index: number) => setSortField(String(columns[index].field)), + ...sortProps, + }); + +const getTableColumnSortByIndex = + ({ + columns, + subColumns, + sortIndex, + sortDirection, + setSortIndex, + setSortDirection, + }: TableColumnSortByIndexProps): GetColumnSort => + (columnIndex: number) => + (columnIndex < columns.length + ? columns[columnIndex] + : subColumns?.[columnIndex - columns.length] + )?.sortable + ? { + sortBy: { + index: sortIndex, + direction: sortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setSortIndex(index); + setSortDirection(direction); + }, + columnIndex, + } + : undefined; +/** + * Using PF Composable Tables, this utility will help with handling sort logic. + * + * Use `transformData` on your data before you render rows. + * Use `getColumnSort` on your Th.sort as you render it (using the index of your column) + * + * @see https://www.patternfly.org/v4/components/table + */ +const useTableColumnSort = ( + columns: SortableData[], + subColumns: SortableData[], + defaultSortColIndex?: number, +): { + transformData: (data: T[]) => T[]; + getColumnSort: GetColumnSort; +} => { + const [activeSortIndex, setActiveSortIndex] = React.useState( + defaultSortColIndex, + ); + const [activeSortDirection, setActiveSortDirection] = React.useState<'desc' | 'asc' | undefined>( + 'asc', + ); + + return { + transformData: (data: T[]): T[] => { + if (activeSortIndex === undefined) { + return data; + } + + return data.toSorted((a, b) => { + const columnField = + activeSortIndex < columns.length + ? columns[activeSortIndex] + : subColumns[activeSortIndex - columns.length]; + + const compute = () => { + if (typeof columnField.sortable === 'function') { + return columnField.sortable(a, b, columnField.field); + } + + if (!columnField.field) { + // If you lack the field, no auto sorting can be done + return 0; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const dataValueA = a[columnField.field as keyof T]; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const dataValueB = b[columnField.field as keyof T]; + if (typeof dataValueA === 'string' && typeof dataValueB === 'string') { + return dataValueA.localeCompare(dataValueB); + } + if (typeof dataValueA === 'number' && typeof dataValueB === 'number') { + return dataValueA - dataValueB; + } + return 0; + }; + + return compute() * (activeSortDirection === 'desc' ? -1 : 1); + }); + }, + getColumnSort: getTableColumnSortByIndex({ + columns, + subColumns, + sortDirection: activeSortDirection, + setSortDirection: setActiveSortDirection, + sortIndex: activeSortIndex, + setSortIndex: setActiveSortIndex, + }), + }; +}; + +export default useTableColumnSort; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx index 40050b5a..6e318440 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import ModelRegistry from './screens/ModelRegistry'; import ModelRegistryCoreLoader from './ModelRegistryCoreLoader'; import { modelRegistryUrl } from './screens/routeUtils'; +import RegisteredModelsArchive from './screens/RegisteredModelsArchive/RegisteredModelsArchive'; const ModelRegistryRoutes: React.FC = () => ( @@ -15,6 +16,11 @@ const ModelRegistryRoutes: React.FC = () => ( } > } /> + + } /> + } /> + + } /> ); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistry.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistry.tsx index d37dda7a..ddc33484 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistry.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelRegistry.tsx @@ -3,7 +3,9 @@ import ApplicationsPage from '~/app/components/ApplicationsPage'; import TitleWithIcon from '~/app/components/design/TitleWithIcon'; import { ProjectObjectType } from '~/app/components/design/utils'; import useRegisteredModels from '~/app/hooks/useRegisteredModels'; +import { filterLiveModels } from '~/app/utils'; import ModelRegistrySelectorNavigator from './ModelRegistrySelectorNavigator'; +import RegisteredModelListView from './RegisteredModels/RegisteredModelListView'; import { modelRegistryUrl } from './routeUtils'; type ModelRegistryProps = Omit< @@ -18,7 +20,7 @@ type ModelRegistryProps = Omit< >; const ModelRegistry: React.FC = ({ ...pageProps }) => { - const [, loaded, loadError] = useRegisteredModels(); + const [registeredModels, loaded, loadError, refresh] = useRegisteredModels(); return ( = ({ ...pageProps }) => { provideChildrenPadding removeChildrenTopPadding > - TODO: Add table of registered models; + ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx new file mode 100644 index 00000000..d2a6367d --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { SearchInput, ToolbarFilter, ToolbarGroup, ToolbarItem } from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import { useNavigate } from 'react-router-dom'; +import { RegisteredModel } from '~/app/types'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import { SearchType } from '~/app/components/DashboardSearchField'; +import { ProjectObjectType, typedEmptyImage } from '~/app/components/design/utils'; +import { asEnumMember, filterRegisteredModels } from '~/app/utils'; +import SimpleSelect from '~/app/components/SimpleSelect'; +import { + registeredModelArchiveUrl, + registerModelUrl, +} from '~/app/pages/modelRegistry/screens/routeUtils'; +import EmptyModelRegistryState from '~/app/pages/modelRegistry/screens/components/EmptyModelRegistryState'; +import RegisteredModelTable from './RegisteredModelTable'; +import RegisteredModelsTableToolbar from './RegisteredModelsTableToolbar'; + +type RegisteredModelListViewProps = { + registeredModels: RegisteredModel[]; + refresh: () => void; +}; + +const RegisteredModelListView: React.FC = ({ + registeredModels: unfilteredRegisteredModels, + refresh, +}) => { + const navigate = useNavigate(); + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + const [searchType, setSearchType] = React.useState(SearchType.KEYWORD); + const [search, setSearch] = React.useState(''); + + const searchTypes = React.useMemo(() => [SearchType.KEYWORD, SearchType.OWNER], []); + + if (unfilteredRegisteredModels.length === 0) { + return ( + ( + missing model + )} + description={`${preferredModelRegistry?.name} has no active registered models. Register a model in this registry, or select a different registry.`} + primaryActionText="Register model" + secondaryActionText="View archived models" + primaryActionOnClick={() => { + navigate(registerModelUrl(preferredModelRegistry?.name)); + }} + secondaryActionOnClick={() => { + navigate(registeredModelArchiveUrl(preferredModelRegistry?.name)); + }} + /> + ); + } + + const filteredRegisteredModels = filterRegisteredModels( + unfilteredRegisteredModels, + search, + searchType, + ); + + const resetFilters = () => { + setSearch(''); + }; + + const toggleGroupItems = ( + + setSearch('')} + deleteLabelGroup={() => setSearch('')} + categoryName="Keyword" + > + ({ + key, + label: key, + }))} + value={searchType} + onChange={(newSearchType) => { + const newSearchTypeInput = asEnumMember(newSearchType, SearchType); + if (newSearchTypeInput !== null) { + setSearchType(newSearchTypeInput); + } + }} + icon={} + /> + + + { + setSearch(searchValue); + }} + onClear={() => setSearch('')} + style={{ minWidth: '200px' }} + data-testid="registered-model-table-search" + /> + + + ); + + return ( + } + /> + ); +}; + +export default RegisteredModelListView; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx new file mode 100644 index 00000000..c46ac1b4 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Table } from '~/app/components/table'; +import { RegisteredModel } from '~/app/types'; +import DashboardEmptyTableView from '~/app/components/DashboardEmptyTableView'; +import { rmColumns } from './RegisteredModelsTableColumns'; +import RegisteredModelTableRow from './RegisteredModelTableRow'; + +type RegisteredModelTableProps = { + clearFilters: () => void; + registeredModels: RegisteredModel[]; + refresh: () => void; +} & Partial, 'toolbarContent'>>; + +const RegisteredModelTable: React.FC = ({ + clearFilters, + registeredModels, + toolbarContent, + refresh, +}) => ( + } + rowRenderer={(rm) => ( + + )} + /> +); + +export default RegisteredModelTable; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx new file mode 100644 index 00000000..881e0033 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx @@ -0,0 +1,124 @@ +import * as React from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; +import { Content, ContentVariants, FlexItem, Truncate } from '@patternfly/react-core'; +import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; +import { ModelState, RegisteredModel } from '~/app/types'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import ModelLabels from '~/app/pages/modelRegistry/screens/components/ModelLabels'; +import ModelTimestamp from '~/app/pages/modelRegistry/screens/components/ModelTimestamp'; +import { ArchiveRegisteredModelModal } from '~/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal'; +import { RestoreRegisteredModelModal } from '~/app/pages/modelRegistry/screens/components/RestoreRegisteredModel'; +import { + registeredModelArchiveDetailsUrl, + registeredModelUrl, +} from '~/app/pages/modelRegistry/screens/routeUtils'; + +type RegisteredModelTableRowProps = { + registeredModel: RegisteredModel; + isArchiveRow?: boolean; + refresh: () => void; +}; + +const RegisteredModelTableRow: React.FC = ({ + registeredModel: rm, + isArchiveRow, + refresh, +}) => { + const { apiState } = React.useContext(ModelRegistryContext); + const navigate = useNavigate(); + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + const [isArchiveModalOpen, setIsArchiveModalOpen] = React.useState(false); + const [isRestoreModalOpen, setIsRestoreModalOpen] = React.useState(false); + const rmUrl = registeredModelUrl(rm.id, preferredModelRegistry?.name); + + const actions = [ + { + title: 'View details', + // eslint-disable-next-line @typescript-eslint/no-empty-function + onClick: () => {}, // TODO: @Griffin-Sullivan uncomment this once model versions is active ---> navigate(`${rmUrl}/${ModelVersionsTab.DETAILS}`), + }, + isArchiveRow + ? { + title: 'Restore model', + onClick: () => setIsRestoreModalOpen(true), + } + : { + title: 'Archive model', + onClick: () => setIsArchiveModalOpen(true), + }, + ]; + + return ( + + + + + + + + ); +}; + +export default RegisteredModelTableRow; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts new file mode 100644 index 00000000..bd7e4309 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts @@ -0,0 +1,42 @@ +import { SortableData } from '~/app/components/table'; +import { RegisteredModel } from '~/app/types'; + +export const rmColumns: SortableData[] = [ + { + field: 'model name', + label: 'Model name', + sortable: (a, b) => a.name.localeCompare(b.name), + width: 40, + }, + { + field: 'labels', + label: 'Labels', + sortable: false, + width: 35, + }, + { + field: 'last_modified', + label: 'Last modified', + sortable: (a: RegisteredModel, b: RegisteredModel): number => { + const first = parseInt(a.lastUpdateTimeSinceEpoch); + const second = parseInt(b.lastUpdateTimeSinceEpoch); + return new Date(second).getTime() - new Date(first).getTime(); + }, + }, + { + field: 'owner', + label: 'Owner', + sortable: true, + info: { + tooltip: 'The owner is the user who registered the model.', + tooltipProps: { + isContentLeftAligned: true, + }, + }, + }, + { + field: 'kebab', + label: '', + sortable: false, + }, +]; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx new file mode 100644 index 00000000..877d1fee --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; +import { + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + MenuToggleAction, + MenuToggleElement, + Toolbar, + ToolbarContent, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { EllipsisVIcon, FilterIcon } from '@patternfly/react-icons'; +import { useNavigate } from 'react-router-dom'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import { + registeredModelArchiveUrl, + registerModelUrl, + registerVersionUrl, +} from '~/app/pages/modelRegistry/screens/routeUtils'; + +type RegisteredModelsTableToolbarProps = { + toggleGroupItems?: React.ReactNode; +}; + +const RegisteredModelsTableToolbar: React.FC = ({ + toggleGroupItems: tableToggleGroupItems, +}) => { + const navigate = useNavigate(); + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + const [isRegisterNewVersionOpen, setIsRegisterNewVersionOpen] = React.useState(false); + const [isArchivedModelKebabOpen, setIsArchivedModelKebabOpen] = React.useState(false); + + const tooltipRef = React.useRef(null); + + return ( + + + } breakpoint="xl"> + {tableToggleGroupItems} + + + setIsRegisterNewVersionOpen(false)} + onOpenChange={(isOpen) => setIsRegisterNewVersionOpen(isOpen)} + toggle={(toggleRef) => ( + setIsRegisterNewVersionOpen(!isRegisterNewVersionOpen)} + isExpanded={isRegisterNewVersionOpen} + splitButtonOptions={{ + variant: 'action', + items: [ + navigate(registerModelUrl(preferredModelRegistry?.name))} + > + Register model + , + ], + }} + aria-label="Register model toggle" + data-testid="register-model-split-button" + /> + )} + > + + { + navigate(registerVersionUrl(preferredModelRegistry?.name)); + }} + ref={tooltipRef} + > + Register new version + + + + + + setIsArchivedModelKebabOpen(false)} + onOpenChange={(isOpen: boolean) => setIsArchivedModelKebabOpen(isOpen)} + toggle={(tr: React.Ref) => ( + setIsArchivedModelKebabOpen(!isArchivedModelKebabOpen)} + isExpanded={isArchivedModelKebabOpen} + aria-label="View archived models" + > + + + )} + shouldFocusToggleOnSelect + > + + navigate(registeredModelArchiveUrl(preferredModelRegistry?.name))} + > + View archived models + + + + + + + ); +}; + +export default RegisteredModelsTableToolbar; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx new file mode 100644 index 00000000..00aa63ab --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; +import ApplicationsPage from '~/app/components/ApplicationsPage'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import { filterArchiveModels } from '~/app/pages/modelRegistry/screens/utils'; +import useRegisteredModels from '~/app/hooks/useRegisteredModels'; +import RegisteredModelsArchiveListView from './RegisteredModelsArchiveListView'; + +type RegisteredModelsArchiveProps = Omit< + React.ComponentProps, + 'breadcrumb' | 'title' | 'loadError' | 'loaded' | 'provideChildrenPadding' +>; + +const RegisteredModelsArchive: React.FC = ({ ...pageProps }) => { + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + const [registeredModels, loaded, loadError, refresh] = useRegisteredModels(); + + return ( + + ( + Model registry - {preferredModelRegistry?.name} + )} + /> + + Archived models + + + } + title={`Archived models of ${preferredModelRegistry?.name}`} + loadError={loadError} + loaded={loaded} + provideChildrenPadding + > + + + ); +}; + +export default RegisteredModelsArchive; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx new file mode 100644 index 00000000..cd419d03 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import { + SearchInput, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import { RegisteredModel } from '~/app/types'; +import { SearchType } from '~/app/components/DashboardSearchField'; +import { filterRegisteredModels } from '~/app/pages/modelRegistry/screens/utils'; +import EmptyModelRegistryState from '~/app/pages/modelRegistry/screens/components/EmptyModelRegistryState'; +import SimpleSelect from '~/app/components/SimpleSelect'; +import { asEnumMember } from '~/app/utils'; +import RegisteredModelsArchiveTable from './RegisteredModelsArchiveTable'; + +type RegisteredModelsArchiveListViewProps = { + registeredModels: RegisteredModel[]; + refresh: () => void; +}; + +const RegisteredModelsArchiveListView: React.FC = ({ + registeredModels: unfilteredRegisteredModels, + refresh, +}) => { + const [searchType, setSearchType] = React.useState(SearchType.KEYWORD); + const [search, setSearch] = React.useState(''); + + const searchTypes = [SearchType.KEYWORD, SearchType.AUTHOR]; + + const filteredRegisteredModels = filterRegisteredModels( + unfilteredRegisteredModels, + search, + searchType, + ); + + if (unfilteredRegisteredModels.length === 0) { + return ( + + ); + } + + return ( + setSearch('')} + registeredModels={filteredRegisteredModels} + toolbarContent={ + + } breakpoint="xl"> + + setSearch('')} + deleteLabelGroup={() => setSearch('')} + categoryName="Keyword" + > + ({ + key, + label: key, + }))} + value={searchType} + onChange={(newSearchType) => { + const newSearchTypeInput = asEnumMember(newSearchType, SearchType); + if (newSearchTypeInput !== null) { + setSearchType(newSearchTypeInput); + } + }} + icon={} + /> + + + { + setSearch(searchValue); + }} + onClear={() => setSearch('')} + style={{ minWidth: '200px' }} + data-testid="registered-models-archive-table-search" + /> + + + + + } + /> + ); +}; + +export default RegisteredModelsArchiveListView; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveTable.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveTable.tsx new file mode 100644 index 00000000..30aca1f6 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveTable.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Table } from '~/app/components/table'; +import { RegisteredModel } from '~/app/types'; +import { rmColumns } from '~/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns'; +import DashboardEmptyTableView from '~/app/components/DashboardEmptyTableView'; +import RegisteredModelTableRow from '~/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow'; + +type RegisteredModelsArchiveTableProps = { + clearFilters: () => void; + registeredModels: RegisteredModel[]; + refresh: () => void; +} & Partial, 'toolbarContent'>>; + +const RegisteredModelsArchiveTable: React.FC = ({ + clearFilters, + registeredModels, + toolbarContent, + refresh, +}) => ( +
+
+ + + + + +
+ {rm.description && ( + + + + )} +
+ + + + + + {rm.owner || '-'} + + + + setIsArchiveModalOpen(false)} + onSubmit={() => + apiState.api + .patchRegisteredModel( + {}, + { + state: ModelState.ARCHIVED, + }, + rm.id, + ) + .then(refresh) + } + isOpen={isArchiveModalOpen} + registeredModelName={rm.name} + /> + setIsRestoreModalOpen(false)} + onSubmit={() => + apiState.api + .patchRegisteredModel( + {}, + { + state: ModelState.LIVE, + }, + rm.id, + ) + .then(() => navigate(registeredModelUrl(rm.id, preferredModelRegistry?.name))) + } + isOpen={isRestoreModalOpen} + registeredModelName={rm.name} + /> +
} + rowRenderer={(rm) => ( + + )} + /> +); + +export default RegisteredModelsArchiveTable; 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 new file mode 100644 index 00000000..233ed877 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { Flex, FlexItem, Stack, StackItem, TextInput } from '@patternfly/react-core'; +import { Modal } from '@patternfly/react-core/deprecated'; +import DashboardModalFooter from '~/app/components/DashboardModalFooter'; + +// import useNotification from '~/utilities/useNotification'; // TODO: Implement useNotification + +interface ArchiveRegisteredModelModalProps { + onCancel: () => void; + onSubmit: () => void; + isOpen: boolean; + registeredModelName: string; +} + +export const ArchiveRegisteredModelModal: React.FC = ({ + onCancel, + onSubmit, + isOpen, + registeredModelName, +}) => { + // const notification = useNotification(); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [error, setError] = React.useState(); + const [confirmInputValue, setConfirmInputValue] = React.useState(''); + const isDisabled = confirmInputValue.trim() !== registeredModelName || isSubmitting; + + const onClose = React.useCallback(() => { + setConfirmInputValue(''); + onCancel(); + }, [onCancel]); + + const onConfirm = React.useCallback(async () => { + setIsSubmitting(true); + + try { + await onSubmit(); + onClose(); + // notification.success(`${registeredModelName} and all its versions archived.`); + } catch (e) { + if (e instanceof Error) { + setError(e); + } + } finally { + setIsSubmitting(false); + } + }, [onSubmit, onClose]); + + return ( + + } + data-testid="archive-registered-model-modal" + > + + + {registeredModelName} and all of its versions will be archived and unavailable for + use unless it is restored. + + + + + Type {registeredModelName} to confirm archiving: + + setConfirmInputValue(newValue)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !isDisabled) { + onConfirm(); + } + }} + /> + + + + + ); +}; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelLabels.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelLabels.tsx new file mode 100644 index 00000000..e888b696 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelLabels.tsx @@ -0,0 +1,120 @@ +import { Button, Label, LabelGroup, Popover, SearchInput, Content } from '@patternfly/react-core'; +import { Modal, ModalVariant } from '@patternfly/react-core/deprecated'; +import React from 'react'; +import { ModelVersion, RegisteredModel } from '~/app/types'; +import useDebounceCallback from '~/utilities/useDebounceCallback'; +import { getLabels } from '~/app/pages/modelRegistry/screens/utils'; + +// Threshold count to decide whether to choose modal or popover +const MODAL_THRESHOLD = 4; + +type ModelLabelsProps = { + name: string; + customProperties: RegisteredModel['customProperties'] | ModelVersion['customProperties']; +}; + +const ModelLabels: React.FC = ({ name, customProperties }) => { + const [isLabelModalOpen, setIsLabelModalOpen] = React.useState(false); + const [searchValue, setSearchValue] = React.useState(''); + + const allLabels = getLabels(customProperties); + const filteredLabels = allLabels.filter( + (label) => label && label.toLowerCase().includes(searchValue.toLowerCase()), + ); + + const doSetSearchDebounced = useDebounceCallback(setSearchValue); + + const labelsComponent = (labels: string[], textMaxWidth?: string) => + labels.map((label, index) => ( + + )); + + const getLabelComponent = (labels: JSX.Element[]) => { + const labelCount = labels.length; + if (labelCount) { + return labelCount > MODAL_THRESHOLD + ? getLabelModal(labelCount) + : getLabelPopover(labelCount, labels); + } + return null; + }; + + const getLabelPopover = (labelCount: number, labels: JSX.Element[]) => ( + + {labels} + + } + > + + + ); + + const getLabelModal = (labelCount: number) => ( + + ); + + const labelModal = ( + setIsLabelModalOpen(false)} + description={ + + The following are all the labels of {name} + + } + actions={[ + , + ]} + > + doSetSearchDebounced(value)} + onClear={() => setSearchValue('')} + /> +
+ + {labelsComponent(filteredLabels, '50ch')} + +
+ ); + + if (Object.keys(customProperties).length === 0) { + return '-'; + } + + return ( + <> + + {labelsComponent(allLabels.slice(0, 3))} + {getLabelComponent(labelsComponent(allLabels.slice(3)))} + + {labelModal} + + ); +}; + +export default ModelLabels; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelTimestamp.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelTimestamp.tsx new file mode 100644 index 00000000..7fbebbea --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/ModelTimestamp.tsx @@ -0,0 +1,32 @@ +import { Timestamp, TimestampTooltipVariant } from '@patternfly/react-core'; +import React from 'react'; +import { relativeTime } from '~/utilities/time'; + +type ModelTimestampProps = { + timeSinceEpoch?: string; +}; + +const ModelTimestamp: React.FC = ({ timeSinceEpoch }) => { + if (!timeSinceEpoch) { + return '--'; + } + + const time = new Date(parseInt(timeSinceEpoch)).getTime(); + + if (Number.isNaN(time)) { + return '--'; + } + + return ( + + {relativeTime(Date.now(), time)} + + ); +}; + +export default ModelTimestamp; 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 new file mode 100644 index 00000000..66bfec9b --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { Modal } from '@patternfly/react-core/deprecated'; +import DashboardModalFooter from '~/app/components/DashboardModalFooter'; + +// import useNotification from '~/utilities/useNotification'; TODO: Implement useNotification + +interface RestoreRegisteredModelModalProps { + onCancel: () => void; + onSubmit: () => void; + isOpen: boolean; + registeredModelName: string; +} + +export const RestoreRegisteredModelModal: React.FC = ({ + onCancel, + onSubmit, + isOpen, + registeredModelName, +}) => { + // const notification = useNotification(); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [error, setError] = React.useState(); + + const onClose = React.useCallback(() => { + onCancel(); + }, [onCancel]); + + const onConfirm = React.useCallback(async () => { + setIsSubmitting(true); + + try { + await onSubmit(); + onClose(); + // notification.success(`${registeredModelName} and all its versions restored.`); + } catch (e) { + if (e instanceof Error) { + setError(e); + } + } finally { + setIsSubmitting(false); + } + }, [onSubmit, onClose]); + + return ( + + } + data-testid="restore-registered-model-modal" + > + {registeredModelName} and all of its versions will be restored and returned to the + registered models list. + + ); +}; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts new file mode 100644 index 00000000..ff6d4ef4 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts @@ -0,0 +1,150 @@ +import { SearchType } from '~/app/components/DashboardSearchField'; +import { + ModelRegistryCustomProperties, + ModelRegistryMetadataType, + ModelRegistryStringCustomProperties, + ModelState, + ModelVersion, + RegisteredModel, +} from '~/app/types'; +import { KeyValuePair } from '~/types'; + +// Retrieves the labels from customProperties that have non-empty string_value. +export const getLabels = (customProperties: T): string[] => + Object.keys(customProperties).filter((key) => { + const prop = customProperties[key]; + return prop.metadataType === ModelRegistryMetadataType.STRING && prop.string_value === ''; + }); + +// Returns the customProperties object with an updated set of labels (non-empty string_value) without affecting other properties. +export const mergeUpdatedLabels = ( + customProperties: ModelRegistryCustomProperties, + updatedLabels: string[], +): ModelRegistryCustomProperties => { + const existingLabels = getLabels(customProperties); + const addedLabels = updatedLabels.filter((label) => !existingLabels.includes(label)); + const removedLabels = existingLabels.filter((label) => !updatedLabels.includes(label)); + const customPropertiesCopy = { ...customProperties }; + removedLabels.forEach((label) => { + delete customPropertiesCopy[label]; + }); + addedLabels.forEach((label) => { + customPropertiesCopy[label] = { + // eslint-disable-next-line camelcase + string_value: '', + metadataType: ModelRegistryMetadataType.STRING, + }; + }); + return customPropertiesCopy; +}; + +// Retrives the customProperties that are not labels (they have a defined string_value). +export const getProperties = ( + customProperties: T, +): ModelRegistryStringCustomProperties => { + const initial: ModelRegistryStringCustomProperties = {}; + return Object.keys(customProperties).reduce((acc, key) => { + const prop = customProperties[key]; + if (prop.metadataType === ModelRegistryMetadataType.STRING && prop.string_value !== '') { + return { ...acc, [key]: prop }; + } + return acc; + }, initial); +}; + +// Returns the customProperties object with a single string property added, updated or deleted +export const mergeUpdatedProperty = ( + args: { customProperties: ModelRegistryCustomProperties } & ( + | { op: 'create'; newPair: KeyValuePair } + | { op: 'update'; oldKey: string; newPair: KeyValuePair } + | { op: 'delete'; oldKey: string } + ), +): ModelRegistryCustomProperties => { + const { op } = args; + const customPropertiesCopy = { ...args.customProperties }; + if (op === 'delete' || (op === 'update' && args.oldKey !== args.newPair.key)) { + delete customPropertiesCopy[args.oldKey]; + } + if (op === 'create' || op === 'update') { + const { key, value } = args.newPair; + customPropertiesCopy[key] = { + // eslint-disable-next-line camelcase + string_value: value, + metadataType: ModelRegistryMetadataType.STRING, + }; + } + return customPropertiesCopy; +}; + +export const filterModelVersions = ( + unfilteredModelVersions: ModelVersion[], + search: string, + searchType: SearchType, +): ModelVersion[] => + unfilteredModelVersions.filter((mv: ModelVersion) => { + if (!search) { + return true; + } + + switch (searchType) { + case SearchType.KEYWORD: + return ( + mv.name.toLowerCase().includes(search.toLowerCase()) || + (mv.description && mv.description.toLowerCase().includes(search.toLowerCase())) + ); + + case SearchType.AUTHOR: + return ( + mv.author && + (mv.author.toLowerCase().includes(search.toLowerCase()) || + (mv.author && mv.author.toLowerCase().includes(search.toLowerCase()))) + ); + + default: + return true; + } + }); + +export const sortModelVersionsByCreateTime = (registeredModels: ModelVersion[]): ModelVersion[] => + registeredModels.toSorted((a, b) => { + const first = parseInt(a.createTimeSinceEpoch); + const second = parseInt(b.createTimeSinceEpoch); + return new Date(second).getTime() - new Date(first).getTime(); + }); + +export const filterRegisteredModels = ( + unfilteredRegisteredModels: RegisteredModel[], + search: string, + searchType: SearchType, +): RegisteredModel[] => + unfilteredRegisteredModels.filter((rm: RegisteredModel) => { + if (!search) { + return true; + } + + switch (searchType) { + case SearchType.KEYWORD: + return ( + rm.name.toLowerCase().includes(search.toLowerCase()) || + (rm.description && rm.description.toLowerCase().includes(search.toLowerCase())) + ); + + case SearchType.OWNER: + return rm.owner && rm.owner.toLowerCase().includes(search.toLowerCase()); + + default: + return true; + } + }); + +export const filterArchiveVersions = (modelVersions: ModelVersion[]): ModelVersion[] => + modelVersions.filter((mv) => mv.state === ModelState.ARCHIVED); + +export const filterLiveVersions = (modelVersions: ModelVersion[]): ModelVersion[] => + modelVersions.filter((mv) => mv.state === ModelState.LIVE); + +export const filterArchiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => + registeredModels.filter((rm) => rm.state === ModelState.ARCHIVED); + +export const filterLiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => + registeredModels.filter((rm) => rm.state === ModelState.LIVE); diff --git a/clients/ui/frontend/src/app/types.ts b/clients/ui/frontend/src/app/types.ts index da39d133..27348cd6 100644 --- a/clients/ui/frontend/src/app/types.ts +++ b/clients/ui/frontend/src/app/types.ts @@ -22,8 +22,9 @@ export type ModelRegistry = { }; // TODO: Change in the backend AND frontend to "items" instead of "model-registries" -export type ModelRegistryResponse = { - model_registry: ModelRegistry[]; +export type ModelRegistryResponse = { + data: T; + metadata?: Record; }; export enum ModelRegistryMetadataType { diff --git a/clients/ui/frontend/src/app/utils.ts b/clients/ui/frontend/src/app/utils.ts new file mode 100644 index 00000000..bced9569 --- /dev/null +++ b/clients/ui/frontend/src/app/utils.ts @@ -0,0 +1,48 @@ +import { SearchType } from '~/app/components/DashboardSearchField'; +import { ModelState, RegisteredModel } from '~/app/types'; + +export const asEnumMember = ( + member: T[keyof T] | string | number | undefined | null, + e: T, +): T[keyof T] | null => (isEnumMember(member, e) ? member : null); + +export const isEnumMember = ( + member: T[keyof T] | string | number | undefined | unknown | null, + e: T, +): member is T[keyof T] => { + if (member != null) { + return Object.entries(e) + .filter(([key]) => Number.isNaN(Number(key))) + .map(([, value]) => value) + .includes(member); + } + return false; +}; + +export const filterLiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => + registeredModels.filter((rm) => rm.state === ModelState.LIVE); + +export const filterRegisteredModels = ( + unfilteredRegisteredModels: RegisteredModel[], + search: string, + searchType: SearchType, +): RegisteredModel[] => + unfilteredRegisteredModels.filter((rm: RegisteredModel) => { + if (!search) { + return true; + } + + switch (searchType) { + case SearchType.KEYWORD: + return ( + rm.name.toLowerCase().includes(search.toLowerCase()) || + (rm.description && rm.description.toLowerCase().includes(search.toLowerCase())) + ); + + case SearchType.OWNER: + return rm.owner && rm.owner.toLowerCase().includes(search.toLowerCase()); + + default: + return true; + } + }); diff --git a/clients/ui/frontend/src/images/no-models-model-registry.svg b/clients/ui/frontend/src/images/no-models-model-registry.svg new file mode 100644 index 00000000..5b3e712b --- /dev/null +++ b/clients/ui/frontend/src/images/no-models-model-registry.svg @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/ui/frontend/src/images/no-versions-model-registry.svg b/clients/ui/frontend/src/images/no-versions-model-registry.svg new file mode 100644 index 00000000..8c4fa99e --- /dev/null +++ b/clients/ui/frontend/src/images/no-versions-model-registry.svg @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/ui/frontend/src/types.ts b/clients/ui/frontend/src/types.ts index 34f4c36f..b40371e0 100644 --- a/clients/ui/frontend/src/types.ts +++ b/clients/ui/frontend/src/types.ts @@ -19,3 +19,8 @@ export type CommonConfig = { export type FeatureFlag = { modelRegistry: boolean; }; + +export type KeyValuePair = { + key: string; + value: string; +}; diff --git a/clients/ui/frontend/src/utilities/markdown.ts b/clients/ui/frontend/src/utilities/markdown.ts new file mode 100644 index 00000000..9b0b2ec3 --- /dev/null +++ b/clients/ui/frontend/src/utilities/markdown.ts @@ -0,0 +1,53 @@ +import DOMPurify from 'dompurify'; +import { Converter } from 'showdown'; + +export const markdownConverter = { + makeHtml: (markdown: string): string => { + const unsafeHtml = new Converter({ + tables: true, + openLinksInNewWindow: true, + strikethrough: true, + emoji: true, + literalMidWordUnderscores: true, + }).makeHtml(markdown); + + // add hook to transform anchor tags + DOMPurify.addHook('beforeSanitizeElements', (node) => { + // nodeType 1 = element type + if (node.nodeType === 1 && node.nodeName.toLowerCase() === 'a') { + node.setAttribute('rel', 'noopener noreferrer'); + } + }); + + return DOMPurify.sanitize(unsafeHtml, { + ALLOWED_TAGS: [ + 'b', + 'i', + 'strike', + 's', + 'del', + 'em', + 'strong', + 'a', + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'ul', + 'ol', + 'li', + 'code', + 'pre', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + ], + ALLOWED_ATTR: ['href', 'target', 'rel', 'class'], + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, + }); + }, +}; diff --git a/clients/ui/frontend/src/utilities/time.ts b/clients/ui/frontend/src/utilities/time.ts new file mode 100644 index 00000000..16fd8a8e --- /dev/null +++ b/clients/ui/frontend/src/utilities/time.ts @@ -0,0 +1,69 @@ +// TODO: Trimed down version of the original file. Needs to be updated with the original file. + +const printAgo = (time: number, unit: string) => `${time} ${unit}${time > 1 ? 's' : ''} ago`; +const printIn = (time: number, unit: string) => `in ${time} ${unit}${time > 1 ? 's' : ''}`; + +export const relativeTime = (current: number, previous: number): string => { + const msPerMinute = 60 * 1000; + const msPerHour = msPerMinute * 60; + const msPerDay = msPerHour * 24; + const msPerMonth = msPerDay * 30; + const msPerYear = msPerDay * 365; + + if (Number.isNaN(previous)) { + return 'Just now'; + } + + let elapsed = current - previous; + let shortPrintFn = printAgo; + + if (elapsed < 0) { + elapsed *= -1; + shortPrintFn = printIn; + } + + if (elapsed < msPerMinute) { + return 'Just now'; + } + if (elapsed < msPerHour) { + return shortPrintFn(Math.round(elapsed / msPerMinute), 'minute'); + } + if (elapsed < msPerDay) { + return shortPrintFn(Math.round(elapsed / msPerHour), 'hour'); + } + if (elapsed < msPerMonth) { + return shortPrintFn(Math.round(elapsed / msPerDay), 'day'); + } + if (elapsed < msPerYear) { + return shortPrintFn(Math.round(elapsed / msPerMonth), 'month'); + } + const date = new Date(previous); + + const month = date.getMonth(); + let monthAsString = 'Jan'; + if (month === 1) { + monthAsString = 'Feb'; + } else if (month === 2) { + monthAsString = 'Mar'; + } else if (month === 3) { + monthAsString = 'April'; + } else if (month === 4) { + monthAsString = 'May'; + } else if (month === 5) { + monthAsString = 'June'; + } else if (month === 6) { + monthAsString = 'July'; + } else if (month === 7) { + monthAsString = 'August'; + } else if (month === 8) { + monthAsString = 'Sept'; + } else if (month === 9) { + monthAsString = 'Oct'; + } else if (month === 10) { + monthAsString = 'Nov'; + } else if (month === 11) { + monthAsString = 'Dec'; + } + + return `${date.getDate()} ${monthAsString} ${date.getFullYear()}`; +}; diff --git a/clients/ui/frontend/src/utilities/useDebounceCallback.ts b/clients/ui/frontend/src/utilities/useDebounceCallback.ts new file mode 100644 index 00000000..7fc5fcb5 --- /dev/null +++ b/clients/ui/frontend/src/utilities/useDebounceCallback.ts @@ -0,0 +1,10 @@ +import * as _ from 'lodash-es'; +import * as React from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const useDebounceCallback = any>( + fn: T, + delay = 250, +): ReturnType> => React.useMemo(() => _.debounce(fn, delay), [fn, delay]); + +export default useDebounceCallback;