diff --git a/cypress/e2e/schema/field.spec.js b/cypress/e2e/schema/field.spec.js index 65718885a6..c9289920b0 100644 --- a/cypress/e2e/schema/field.spec.js +++ b/cypress/e2e/schema/field.spec.js @@ -138,7 +138,10 @@ describe("Schema: Fields", () => { .should("have.value", "default value"); // Set min/max character limits - cy.getBySelector(SELECTORS.CHARACTER_LIMIT_CHECKBOX).click(); + cy.getBySelector(SELECTORS.CHARACTER_LIMIT_CHECKBOX) + .find("input") + .check({ force: true }); + cy.wait(200); cy.getBySelector(SELECTORS.MAX_CHARACTER_LIMIT_INPUT) .clear() .type("{end}10000"); diff --git a/cypress/e2e/schema/integration.spec.js b/cypress/e2e/schema/integration.spec.js new file mode 100644 index 0000000000..dd46a11f1c --- /dev/null +++ b/cypress/e2e/schema/integration.spec.js @@ -0,0 +1,442 @@ +import { API_ENDPOINTS } from "../../support/api"; +import genericApi from "../../fixtures/integration-field/generic.json"; +import specialApi from "../../fixtures/integration-field/special.json"; +import apiFields from "../../fixtures/integration-field/fields.json"; +import { DISPLAY_OPTIONS_CONFIG } from "../../../src/shell/components/FieldTypeIntegration/configs"; + +const forceClick = { force: true }; + +const genericTypes = ["simple", "text", "image", "video", "details"]; +const specialTypes = ["shopify", "youtube", "mux", "classy"]; + +const KEY_PATHS = { + generic: { + heading: "name", + subHeading: "team", + thumbnail: "playerImage", + }, + special: { + rootPath: "results", + heading: "title", + subHeading: "color", + thumbnail: "featuredMedia", + detail: "price", + }, +}; + +const MODEL_SCHEMA = { + label: "__INTEGRATION-TEST", + name: "__integration_test", + type: "templateset", + description: "", + parentZUID: null, + listed: true, +}; + +const GENERIC_FIELD_DATA = { + label: "integration generic field", + name: "integration_generic_field", + type: "integration", + endpoint: "https://test.api.com/api/v1/generic.json", +}; + +const SPECIAL_FIELD_DATA = { + label: "integration special field", + name: "integration_special_field", + type: "integration", + endpoint: "https://test.api.com/api/v1/special.json", +}; + +describe("Integration Field", () => { + before(() => { + deleteTestData(); + createTestData(); + }); + + after(() => { + deleteTestData(); + }); + + describe("Add Field", () => { + context("Generic Display Types", () => { + genericTypes.forEach((valueType) => { + it(`${valueType}`.toUpperCase(), () => { + connectToEndpoint(GENERIC_FIELD_DATA.endpoint, valueType, genericApi); + addGenericField(valueType); + }); + // SKIP SUBMISION - No backend support as of now + it.skip(`Submit Generic Type Field- ${valueType})`, () => { + const modelZUID = Cypress.env("modelZUID"); + cy.intercept(`/v1/content/models/${modelZUID}/fields`).as( + "getModelFields" + ); + cy.getElement('[data-cy="FieldFormAddFieldBtn"]').click(); + + cy.wait("@getModelFields").then((interception) => { + const field = interception.response.body.data[1]; + expect(field.dataType).to.equal(GENERIC_FIELD_DATA.type); + expect(field.name).to.equal(GENERIC_FIELD_DATA.name); + expect(field.label).to.equal(GENERIC_FIELD_DATA.label); + }); + }); + }); + }); + + context("Special Display Types", () => { + specialTypes.forEach((valueType) => { + it(`${valueType}`.toUpperCase(), () => { + connectToEndpoint( + `https://test.${valueType}.api.com/api/special`, + valueType, + specialApi + ); + addSpecialField(valueType); + }); + // SKIP SUBMISION - No backend support as of now + it.skip(`Submit Special Type Field - ${valueType})`, () => { + const modelZUID = Cypress.env("modelZUID"); + cy.intercept(`/v1/content/models/${modelZUID}/fields`).as( + "getModelFields" + ); + cy.getElement('[data-cy="FieldFormAddFieldBtn"]').click(); + + cy.wait("@getModelFields").then((interception) => { + const field = interception.response.body.data[1]; + expect(field.dataType).to.equal(GENERIC_FIELD_DATA.type); + expect(field.name).to.equal(GENERIC_FIELD_DATA.name); + expect(field.label).to.equal(GENERIC_FIELD_DATA.label); + }); + }); + }); + }); + }); + + describe("Item Selection", () => { + it("Create Content Item", () => { + const modelZUID = Cypress.env("modelZUID"); + + const newData = apiFields.data.map((item) => ({ + ...item, + contentModelZUID: modelZUID, + name: + item.datatype === "integration" ? GENERIC_FIELD_DATA.name : item.name, + label: + item.datatype === "integration" + ? GENERIC_FIELD_DATA.label + : item.label, + ...(item.datatype === "integration" + ? { + integrationFieldConfig: { + ...item.integrationFieldConfig, + endpoint: GENERIC_FIELD_DATA.endpoint, + }, + } + : {}), + })); + cy.intercept( + "GET", + `/v1/content/models/${modelZUID}/fields?showDeleted=*`, + { + statusCode: 200, + body: { + ...apiFields, + data: newData, + }, + } + ); + + cy.intercept("GET", GENERIC_FIELD_DATA.endpoint, { + statusCode: 200, + body: genericApi, + }); + + cy.visit(`/content/${modelZUID}/new`); + cy.getElement('[data-cy="integrationSelectItemsButton"]').click(); + cy.getElement('[data-cy="integrationSelectionFormDialog"]').should( + "exist" + ); + }); + it("Search Filter with - results", () => { + cy.getElement('[data-cy="integrationSelectionFormSearchBox"] input') + .clear() + .type("Memphis"); + + cy.getElement(".integrationSelectionFormListContainer > div") + .children() + .should("have.length", 3); + }); + it("Search Filter - no items found", () => { + cy.getElement('[data-cy="integrationSelectionFormSearchBox"] input') + .clear() + .type("xxxxxx"); + + cy.getElement('[data-cy="NoResultsContainer"]').should("exist"); + }); + + it("Search Filter - reset search term by clicking Search again button", () => { + cy.getElement('[data-cy="NoResultsContainer"] button').click(); + + cy.getElement('[data-cy="integrationSelectionFormSearchBox"] input') + .should("be.empty") + .should("be.focused"); + }); + it("select 3 Items from the list", () => { + cy.getElement( + '.integrationSelectionFormListContainer > div [data-cy="integrationSelectCard"]:eq(0) input' + ).check({ force: true }); + cy.getElement( + '.integrationSelectionFormListContainer > div [data-cy="integrationSelectCard"]:eq(1) input' + ).check({ force: true }); + cy.getElement( + '.integrationSelectionFormListContainer > div [data-cy="integrationSelectCard"]:eq(2) input' + ).check({ force: true }); + + cy.getElement('[data-cy="selectIntegrationFormDoneButton"]').click(); + + cy.getElement('[data-cy="integrationSelectionFormDialog"]').should( + "not.exist" + ); + + cy.getElement('[data-cy="integrationListValueContainer"]') + .children() + .should("have.length", 3); + }); + it("View Item's JSON data", () => { + cy.getElement( + '[data-cy="integrationListValueContainer"] .draggableCard:eq(0) .moreOptionButton' + ).click(); + cy.getElement( + ".MuiPopover-root.moreOptionMenu ul li.moreOptionMenuItem-view" + ).click(); + cy.getElement(".monaco-editor.integrationJsonViewerEditor").should( + (data) => { + const editorText = data.text().replace(/\s+/g, ""); + const apiText = JSON.stringify(genericApi[0]).replace(/\s+/g, ""); + expect(editorText).to.equal(apiText); + } + ); + cy.getElement('[data-cy="jsonCodeViewerCloseButton"]').click(); + }); + + it("Reorder Item List", () => { + const fistItemName = genericApi[0].name; + const secondItemName = genericApi[1].name; + + const dataTransfer = new DataTransfer(); + + cy.getElement( + '[data-cy="integrationListValueContainer"] .draggableCard:eq(0) .draggableCardDragHandle' + ).trigger("dragstart", { dataTransfer }); + + cy.getElement( + '[data-cy="integrationListValueContainer"] .draggableCard:eq(1)' + ).trigger("drop", { dataTransfer }); + + cy.getElement( + '[data-cy="integrationListValueContainer"] .draggableCard:eq(0)' + ).contains(secondItemName, { matchCase: false }); + cy.getElement( + '[data-cy="integrationListValueContainer"] .draggableCard:eq(1)' + ).contains(fistItemName, { matchCase: false }); + }); + + it("Delete List Item", () => { + cy.getElement( + '[data-cy="integrationListValueContainer"] .draggableCard:eq(0) .moreOptionButton' + ).click(); + cy.getElement( + ".MuiPopover-root.moreOptionMenu ul li.moreOptionMenuItem-remove" + ).click(); + + cy.getElement('[data-cy="integrationListValueContainer"]') + .children() + .should("have.length", 2); + }); + + //SKIPED: no backend support for integration field + it.skip("Save Item", () => { + cy.getElement('[data-cy="CreateItemSaveButton"]').click(forceClick); + cy.getElement( + ".MuiPopover-root.moreOptionMenu ul li.moreOptionMenuItem-remove" + ).click(); + + cy.getElement('[data-cy="integrationListValueContainer"]') + .children() + .should("have.length", 2); + }); + }); +}); + +function connectToEndpoint(endpoint, type, apiData) { + const modelZUID = Cypress.env("modelZUID"); + + cy.intercept("GET", endpoint, { + statusCode: 200, + body: apiData, + }); + cy.visit(`/schema/${Cypress.env("modelZUID")}/fields`); + + cy.getElement('[data-cy="AddFieldBtn"]').click(); + + cy.getElement('[data-cy="FieldItem_integration"]').click(); + + cy.getElement('[data-cy="FieldFormInput_label"] input') + .clear() + .type(`${SPECIAL_FIELD_DATA.label} - ${type}`); + cy.getElement('label:contains("Required field")').click(); + + cy.getElement('[data-cy="integrationConfigureButton"]').click(); + cy.getElement('[data-cy="integrationFormDialog"]').should("exist"); + cy.getElement('[data-cy="integrationEndpointInput"] input') + .clear() + .type(endpoint); + + cy.getElement('[data-cy="integrationConnectButton"]').click(); + + cy.getElement('[data-cy="integrationConnectionStatusContainer"]').should( + "exist" + ); + + cy.getElement('[data-cy="integrationConnectionStatusLabel"]').should( + "contain", + "Connection Successful", + { matchCase: false } + ); + cy.getElement('[data-cy="integrationConnectionStatusButton"]').click(); +} + +function addSpecialField(type) { + cy.getElement(`[data-cy="integrationRecommendedOptionsContainer"]`) + .contains(`${type} card`, { matchCase: false }) + .should("exist"); + + //should be recommended and is active + cy.getElement( + `[data-cy="integrationRecommendedOptionsContainer"] [data-cy="integrationDisplayOption-${type}"]` + ) + .should("exist") + .should("have.attr", "data-selected"); + + cy.getElement('[data-cy="integrationOptionsContainer"]') + .children() + .should("have.length", genericTypes?.length); + + cy.getElement('[data-cy="integrationOtherOptionsContainer"]') + .children() + .should("have.length", specialTypes?.length - 1); + + cy.getElement('[data-cy="integrationConfigureOptionNextButton"]').click(); + + cy.getElement( + `[data-cy="integrationKeyPathSelector-rootPath"] .MuiInputBase-root` + ).click(); + + cy.getElement( + `.MuiAutocomplete-listbox li p:contains("${KEY_PATHS.special.rootPath}")` + ).click(forceClick); + + cy.getElement('[data-cy="integrationConfigureOptionKeyPathContainer"]') + .children() + .should("have.length", DISPLAY_OPTIONS_CONFIG?.[type]?.length); + + DISPLAY_OPTIONS_CONFIG?.[type].forEach((item) => { + cy.getElement( + `[data-cy="integrationKeyPathSelector-${item.name}"]` + ).click(); + cy.getElement( + `.MuiAutocomplete-listbox li p:contains("${ + KEY_PATHS.special?.[item.name] + }")` + ).click(forceClick); + }); +} + +function addGenericField(type) { + cy.getElement(`[data-cy="integrationDisplayOption-${type}"]`).click(); + + cy.getElement('[data-cy="integrationConfigureOptionNextButton"]').click(); + + cy.getElement('[data-cy="integrationConfigureOptionKeyPathContainer"]') + .children() + .should("have.length", DISPLAY_OPTIONS_CONFIG?.[type]?.length); + + DISPLAY_OPTIONS_CONFIG?.[type].forEach((item) => { + if (item.name === "details") { + cy.getElement( + '[data-cy="integrationConfigureDisplayOptionsAddDetailButton"]' + ).click(); + cy.getElement( + '[data-cy="integrationDetailsSelectorRow-0"] .MuiInputBase-root' + ).click(); + + cy.getElement(`.MuiAutocomplete-listbox li:contains("position")`).click( + forceClick + ); + cy.getElement( + '[data-cy="integrationDetailsSelectorRow-1"] .MuiInputBase-root' + ).click(); + + cy.getElement(`.MuiAutocomplete-listbox li:contains("jerseyNo")`).click( + forceClick + ); + } else { + cy.getElement( + `[data-cy="integrationKeyPathSelector-${item.name}"]` + ).click(); + + cy.getElement( + `.MuiAutocomplete-listbox li:contains("${ + KEY_PATHS.generic[item.name] + }")` + ).click(forceClick); + } + }); + + cy.getElement( + '[data-cy="integrationConfigureDisplayOptionsDoneButton"]' + ).click(); + + cy.getElement('[data-cy="integrationApiUrl"] input').should( + "have.value", + GENERIC_FIELD_DATA.endpoint + ); + cy.getElement('[data-cy="integrationDisplayType"] input').should( + "have.value", + type + ); +} + +function createTestData() { + cy.apiRequest({ + url: `${API_ENDPOINTS.devInstance}/content/models`, + method: "POST", + body: MODEL_SCHEMA, + }).then(({ status, data }) => { + Cypress.env("modelZUID", data?.ZUID); + }); +} + +function deleteTestData() { + const labelsForDelete = [MODEL_SCHEMA?.label]; + + cy.apiRequest({ + url: `${API_ENDPOINTS.devInstance}/content/models`, + }).then(({ status, data }) => { + const forDeleteData = data?.filter((item) => + labelsForDelete.includes(item?.label) + ); + + const forDeleteZuids = forDeleteData?.map((del) => del?.ZUID); + + forDeleteZuids?.forEach((zuid) => { + cy.apiRequest({ + url: `${API_ENDPOINTS.devInstance}/content/models/${zuid}`, + method: "DELETE", + }); + }); + }); +} + +Cypress.Commands.add("getElement", (selector) => { + return cy.get(selector, { timeout: 40_000 }); +}); diff --git a/cypress/fixtures/integration-field/fields.json b/cypress/fixtures/integration-field/fields.json new file mode 100644 index 0000000000..f54c3b726f --- /dev/null +++ b/cypress/fixtures/integration-field/fields.json @@ -0,0 +1,74 @@ +{ + "_meta": { + "timestamp": "2025-07-09T22:08:20.97628903Z", + "totalResults": 2, + "start": 0, + "offset": 0, + "limit": 5 + }, + "data": [ + { + "ZUID": "12-9add95a6b7-b3d3lr", + "contentModelZUID": "6-92fda9baef-wx38b3", + "name": "text_field", + "label": "Text Field", + "description": "", + "datatype": "text", + "sort": 0, + "required": false, + "relationship": null, + "options": null, + "fieldOptions": null, + "datatypeOptions": null, + "settings": { + "defaultValue": null, + "list": true + }, + "relatedModelZUID": null, + "relatedFieldZUID": null, + "createdAt": "2025-07-10T02:21:23Z", + "updatedAt": "2025-07-10T02:21:23Z" + }, + { + "ZUID": "12-f8cfdcf2fa-5mlxxz", + "contentModelZUID": "6-92fda9baef-wx38b3", + "label": "integration generic field", + "name": "integration_generic_field", + "description": "Integration Test API Description", + "datatype": "integration", + "sort": 1, + "required": true, + "relationship": null, + "options": null, + "fieldOptions": null, + "datatypeOptions": null, + "integrationFieldConfig": { + "endpoint": "https://test.api.com/api/v1/generic.json", + "headers": null, + "type": "image", + "keyPaths": { + "heading": "name", + "subHeading": "team", + "thumbnail": "playerImage", + "rootPath": null, + "details": [ + "playerId", + "team", + "jerseyNo", + "position", + "height", + "weight" + ] + } + }, + "settings": { + "defaultValue": null, + "list": true, + "minValue": 1, + "maxValue": 5 + }, + "createdAt": "2025-06-18T07:36:49Z", + "updatedAt": "2025-06-18T07:36:49Z" + } + ] +} diff --git a/cypress/fixtures/integration-field/generic.json b/cypress/fixtures/integration-field/generic.json new file mode 100644 index 0000000000..d6c7cca3df --- /dev/null +++ b/cypress/fixtures/integration-field/generic.json @@ -0,0 +1,202 @@ +[ + { + "playerId": "1630173", + "name": "Precious Achiuwa", + "team": "New York - Knicks (NYK)", + "jerseyNo": "5", + "position": "F", + "height": "2.03", + "weight": "110.2 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1630173.png" + }, + { + "playerId": "203500", + "name": "Steven Adams", + "team": "Houston - Rockets (HOU)", + "jerseyNo": "12", + "position": "C", + "height": "2.11", + "weight": "120.2 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/203500.png" + }, + { + "playerId": "1628389", + "name": "Bam Adebayo", + "team": "Miami - Heat (MIA)", + "jerseyNo": "13", + "position": "C-F", + "height": "2.06", + "weight": "115.7 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1628389.png" + }, + { + "playerId": "1630534", + "name": "Ochai Agbaji", + "team": "Toronto - Raptors (TOR)", + "jerseyNo": "30", + "position": "G", + "height": "1.96", + "weight": "97.5 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1630534.png" + }, + { + "playerId": "1630583", + "name": "Santi Aldama", + "team": "Memphis - Grizzlies (MEM)", + "jerseyNo": "7", + "position": "F-C", + "height": "2.13", + "weight": "97.5 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1630583.png" + }, + { + "playerId": "1641725", + "name": "Trey Alexander", + "team": "Denver - Nuggets (DEN)", + "jerseyNo": "23", + "position": "G", + "height": "1.93", + "weight": "83.9 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1641725.png" + }, + { + "playerId": "1629638", + "name": "Nickeil Alexander-Walker", + "team": "Minnesota - Timberwolves (MIN)", + "jerseyNo": "9", + "position": "G", + "height": "1.96", + "weight": "93.0 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1629638.png" + }, + { + "playerId": "1628960", + "name": "Grayson Allen", + "team": "Phoenix - Suns (PHX)", + "jerseyNo": "8", + "position": "G", + "height": "1.93", + "weight": "89.8 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1628960.png" + }, + { + "playerId": "1628386", + "name": "Jarrett Allen", + "team": "Cleveland - Cavaliers (CLE)", + "jerseyNo": "31", + "position": "C", + "height": "2.06", + "weight": "110.2 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1628386.png" + }, + { + "playerId": "1630631", + "name": "Jose Alvarado", + "team": "New Orleans - Pelicans (NOP)", + "jerseyNo": "15", + "position": "G", + "height": "1.83", + "weight": "81.2 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1630631.png" + }, + { + "playerId": "203937", + "name": "Kyle Anderson", + "team": "Miami - Heat (MIA)", + "jerseyNo": "20", + "position": "F-G", + "height": "2.06", + "weight": "104.3 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/203937.png" + }, + { + "playerId": "203507", + "name": "Giannis Antetokounmpo", + "team": "Milwaukee - Bucks (MIL)", + "jerseyNo": "34", + "position": "F", + "height": "2.11", + "weight": "110.2 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/203507.png" + }, + { + "playerId": "1630175", + "name": "Cole Anthony", + "team": "Memphis - Grizzlies (MEM)", + "jerseyNo": "50", + "position": "G", + "height": "1.88", + "weight": "83.9 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1630175.png" + }, + { + "playerId": "1628384", + "name": "OG Anunoby", + "team": "New York - Knicks (NYK)", + "jerseyNo": "8", + "position": "F-G", + "height": "2.01", + "weight": "108.9 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1628384.png" + }, + { + "playerId": "1642379", + "name": "Taran Armstrong", + "team": "Golden State - Warriors (GSW)", + "jerseyNo": "1", + "position": "G", + "height": "1.96", + "weight": "86.2 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1642379.png" + }, + { + "playerId": "1630166", + "name": "Deni Avdija", + "team": "Portland - Trail_blazers (POR)", + "jerseyNo": "8", + "position": "F", + "height": "2.06", + "weight": "108.9 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1630166.png" + }, + { + "playerId": "1629028", + "name": "Deandre Ayton", + "team": "Portland - Trail_blazers (POR)", + "jerseyNo": "2", + "position": "C", + "height": "2.13", + "weight": "114.3 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1629028.png" + }, + { + "playerId": "1628963", + "name": "Marvin Bagley III", + "team": "Memphis - Grizzlies (MEM)", + "jerseyNo": "35", + "position": "F", + "height": "2.08", + "weight": "106.6 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1628963.png" + }, + { + "playerId": "1631116", + "name": "Patrick Baldwin Jr.", + "team": "LA - Clippers (LAC)", + "jerseyNo": "23", + "position": "F", + "height": "2.06", + "weight": "99.8 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1631116.png" + }, + { + "playerId": "1630163", + "name": "LaMelo Ball", + "team": "Charlotte - Hornets (CHA)", + "jerseyNo": "1", + "position": "G", + "height": "2.01", + "weight": "81.6 kg", + "playerImage": "https://cdn.nba.com/headshots/nba/latest/260x190/1630163.png" + } +] diff --git a/cypress/fixtures/integration-field/special.json b/cypress/fixtures/integration-field/special.json new file mode 100644 index 0000000000..66be8d1eb7 --- /dev/null +++ b/cypress/fixtures/integration-field/special.json @@ -0,0 +1,298 @@ +{ + "results": [ + { + "id": 6804843135178, + "sku": "A2A4G", + "title": "Crest Hoodie", + "type": "Mens Hoodie", + "color": "Black", + "inStock": true, + "price": "$40", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/CrestHoodieBlackA2A4G-BBBB-1827_A-Edit.jpg?v=1743417063", + "rating": { + "average": 4.5659, + "range": 5, + "count": 668 + } + }, + { + "id": 6805972385994, + "sku": "A5A9T", + "title": "Crest Oversized Zip Up Hoodie", + "type": "Mens Pullovers", + "color": "Lifestyle Brown", + "inStock": true, + "price": "$48", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-CrestOversizedZipUpHoodieGSLifestyleBrownA5A9T_NC0S_2349_0292.jpg?v=1746438153", + "rating": { + "average": 4.5088, + "range": 5, + "count": 57 + } + }, + { + "id": 6806002139338, + "sku": "A2C1B", + "title": "Arrival Track Jacket", + "type": "Mens Jackets / Outerwear", + "color": "Black", + "inStock": true, + "price": "$56", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-ArrivalWovenTrackJacketGSBlackA2C1B_BB2J_1125_A_A_0207.jpg?v=1746708605", + "rating": { + "range": 5 + } + }, + { + "id": 6805235728586, + "sku": "A4A7J", + "title": "Heritage Washed Hoodie", + "type": "Mens Hoodie", + "color": "Onyx Grey", + "inStock": true, + "price": "$44.8", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/HeritageWashedHoodieGSOnyxGrey-ACIDWASHSMALLBALLA4A7J-GB8N-1430.jpg?v=1695912174", + "rating": { + "average": 4.4779, + "range": 5, + "count": 272 + } + }, + { + "id": 6805481488586, + "sku": "A5A8O", + "title": "Crest Oversized Hoodie", + "type": "Mens Pullovers", + "color": "Black", + "inStock": true, + "price": "$46", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/CrestOversizedHoodieGSBlackA5A8O-BB2J-1282.jpg?v=1713881508", + "rating": { + "average": 4.4157, + "range": 5, + "count": 89 + } + }, + { + "id": 6806110044362, + "sku": "A2B3L", + "title": "Rest Day Essentials Hoodie", + "type": "Mens Pullovers", + "color": "Light Grey Core Marl", + "inStock": true, + "price": "$58", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-RestDayEssentialsBoxyOversizedHoodieLightGreyCoreMarlA2B3L_GBCN_2156_0263.jpg?v=1746437914", + "rating": { + "average": 4.75, + "range": 5, + "count": 4 + } + }, + { + "id": 6806126264522, + "sku": "A2C9H", + "title": "Power Hoodie", + "type": "Mens Hoodie", + "color": "Charcoal Core Marl", + "inStock": true, + "price": "$37.2", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/A_PowerOriginalsHoodieCharcoalCoreMarlA2C9H-GBBB-1262_391700d8-8ec4-4ddb-9d76-a3ce4d42eb27.jpg?v=1743417072", + "rating": { + "average": 4.6389, + "range": 5, + "count": 36 + } + }, + { + "id": 6805481717962, + "sku": "A5A8O", + "title": "Crest Oversized Hoodie", + "type": "Mens Pullovers", + "color": "Light Grey Marl", + "inStock": true, + "price": "$46", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/CrestOversizedHoodieGSLightGreyMarlA5A8O-GCHR-1625.jpg?v=1709107946", + "rating": { + "average": 4.4157, + "range": 5, + "count": 89 + } + }, + { + "id": 6805971828938, + "sku": "A5A8O", + "title": "Crest Oversized Hoodie", + "type": "Mens Pullovers", + "color": "Lifestyle Brown", + "inStock": true, + "price": "$46", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/CrestOversizedHoodieGSLifestyleBrownA5A8O-NC0S-1834_A-Edit-2_5ae29354-fab0-4b7e-a21d-b35571e04ba1.jpg?v=1744898780", + "rating": { + "average": 4.4111, + "range": 5, + "count": 90 + } + }, + { + "id": 6805972287690, + "sku": "A5A8O", + "title": "Crest Oversized Hoodie", + "type": "Mens Pullovers", + "color": "Rest Blue", + "inStock": true, + "price": "$46", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/CrestOversizedHoodieGSRestBlueA5A8O-UDBB0017_c4235ceb-0f70-406d-a422-83e69aaee0cd.jpg?v=1738170561", + "rating": { + "average": 4.4157, + "range": 5, + "count": 89 + } + }, + { + "id": 6805217181898, + "sku": "A5A9T", + "title": "Crest Oversized Zip Up Hoodie", + "type": "Mens Hoodie", + "color": "Black", + "inStock": true, + "price": "$48", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/EssentialOversizedZipUpHoodieGSBlackA5A9T-BB2J-1354.jpg?v=1696606434", + "rating": { + "average": 4.5088, + "range": 5, + "count": 57 + } + }, + { + "id": 6805967929546, + "sku": "A5A9T", + "title": "Crest Oversized Zip Up Hoodie", + "type": "Mens Pullovers", + "color": "Light Grey Core Marl", + "inStock": true, + "price": "$48", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/CrestOversizedZipUpHoodieLightGreyCoreMarlA5A9T-GBCN0023_68aeef6b-fbfe-4206-8600-03abf5037ea6.jpg?v=1738170562", + "rating": { + "average": 4.5088, + "range": 5, + "count": 57 + } + }, + { + "id": 6806073966794, + "sku": "A2B2U", + "title": "Lightweight Slub Textured Hoodie", + "type": "Mens Pullovers", + "color": "Black", + "inStock": true, + "price": "$70", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-LightweightSlubTexturedLiftingHoodieGSBlackA2B2U_BB2J_1305_0084.jpg?v=1747673262", + "rating": { + "range": 5 + } + }, + { + "id": 6806109683914, + "sku": "A2B3M", + "title": "Rest Day Essentials Zip Hoodie", + "type": "Mens Pullovers", + "color": "Light Grey Core Marl", + "inStock": true, + "price": "$60", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-RestDayEssentialsBoxyOversizedZipHoodieLightGreyCoreMarlA2B3M_GBCN_2266_0278.jpg?v=1746437914", + "rating": { + "average": 5, + "range": 5, + "count": 2 + } + }, + { + "id": 6806075637962, + "sku": "A2B2U", + "title": "Lightweight Slub Textured Hoodie", + "type": "Mens Pullovers", + "color": "Utility Green", + "inStock": true, + "price": "$70", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-LightweightSlubTexturedLiftingHoodieGSUtilityGreenA2B2U_ECJP_1310_0089.jpg?v=1747681706", + "rating": { + "range": 5 + } + }, + { + "id": 6806088450250, + "sku": "A2B3O", + "title": "Prime Hoodie", + "type": "Mens Hoodie", + "color": "Black/Vivid Red", + "inStock": true, + "price": "$48", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/PrimeHoodieGSBlack-GSVividRedA2B3O-RBWN-1363-0190_e00f20c9-bd19-4027-897b-602f6dddb2fe.jpg?v=1740429537", + "rating": { + "average": 4, + "range": 5, + "count": 1 + } + }, + { + "id": 6805953839306, + "sku": "A2B3B", + "title": "GSLC Hoodie", + "type": "Mens Pullovers", + "color": "Black", + "inStock": true, + "price": "$58", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-GSLCHoodieGSBlackA2B3B_BB2J_2693.jpg?v=1748014515", + "rating": { + "average": 5, + "range": 5, + "count": 3 + } + }, + { + "id": 6805954068682, + "sku": "A2B3B", + "title": "GSLC Hoodie", + "type": "Mens Pullovers", + "color": "Soft White", + "inStock": true, + "price": "$58", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-GSLCHoodieGSSoftWhiteA2B3B_WCMY_2627.jpg?v=1747678021", + "rating": { + "average": 5, + "range": 5, + "count": 3 + } + }, + { + "id": 6806101295306, + "sku": "A6A7F", + "title": "Interlock Tech Zip Up Hoodie", + "type": "Mens Pullovers", + "color": "Light Grey Core Marl", + "inStock": true, + "price": "$70", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-TechKnitZipUpHoodieLightGreyCoreMarlA6A7F_GBCN_0711.jpg?v=1746693707", + "rating": { + "average": 4.6957, + "range": 5, + "count": 23 + } + }, + { + "id": 6805953904842, + "sku": "A2B3B", + "title": "GSLC Hoodie", + "type": "Mens Pullovers", + "color": "Heavy Blue", + "inStock": true, + "price": "$58", + "featuredMedia": "https://cdn.shopify.com/s/files/1/0156/6146/files/images-GSLCHoodieGSHeavyBlueA2B3B_UCTN_1679.jpg?v=1748014575", + "rating": { + "average": 5, + "range": 5, + "count": 3 + } + } + ] +} diff --git a/public/images/classyIcon.svg b/public/images/classyIcon.svg new file mode 100644 index 0000000000..3702ea64e1 --- /dev/null +++ b/public/images/classyIcon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/integration-sample-image.png b/public/images/integration-sample-image.png new file mode 100644 index 0000000000..5d426a0a53 Binary files /dev/null and b/public/images/integration-sample-image.png differ diff --git a/public/images/integration-sample-video.png b/public/images/integration-sample-video.png new file mode 100644 index 0000000000..02cc29ea6c Binary files /dev/null and b/public/images/integration-sample-video.png differ diff --git a/public/images/muxIcon.svg b/public/images/muxIcon.svg new file mode 100644 index 0000000000..4913d88dc5 --- /dev/null +++ b/public/images/muxIcon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/shopifyIcon.svg b/public/images/shopifyIcon.svg new file mode 100644 index 0000000000..d88932db2d --- /dev/null +++ b/public/images/shopifyIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx index a08185af9a..1658d3b0a2 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx @@ -41,7 +41,6 @@ import { FieldTypeSort } from "../../../../../../../shell/components/FieldTypeSo import { FieldTypeNumber } from "../../../../../../../shell/components/FieldTypeNumber"; import { FieldTypeBlockSelector } from "../../../../../../../shell/components/FieldTypeBlockSelector"; import { InternalLink } from "./InternalLink"; - import styles from "./Field.less"; import { MemoryRouter } from "react-router"; import { withAI } from "../../../../../../../shell/components/withAi"; @@ -53,6 +52,7 @@ import { import { FieldTypeMedia } from "../../FieldTypeMedia"; import { debounce, parseInt } from "lodash"; import { useRegisterRef } from "../../../../../../../engine/useRegisterRef"; +import IntegrationFieldSelect from "../../../../../../../shell/components/FieldTypeIntegration/IntegrationFieldSelect"; import { useDebouncedInput } from "../../../../../../../shell/hooks/useDebouncedInput"; import { format as fmt } from "date-fns"; @@ -184,6 +184,7 @@ export const Field = memo( "dropdown", "date", "datetime", + "integration", ].includes(datatype), } ); @@ -782,6 +783,19 @@ export const Field = memo( /> ); + case "integration": + return ( + + []) : null} + onChange={(value) => onChange(value, name)} + /> + + ); default: return ( diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx index 52f79ba468..07bdec719b 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx @@ -221,7 +221,9 @@ export const ContentInsights = ({}) => { if (!!value) { value = cleanContent(String(value)); - words = [...words, ...value.split(" ")]; + if (typeof value === "string") { + words = [...words, ...value.split(" ")]; + } } }); diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index 09d7158381..976561ef76 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -256,6 +256,14 @@ const fieldTypeColumnConfigMap = { filterable: true, renderCell: (params: GridRenderCellParams) => , }, + integration: { + width: 240, + filterable: true, + renderCell: (params: any) => + params.value && ( + {params.value?.toUpperCase()} + ), + }, } as const; export const ItemListTable = memo( diff --git a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx index 830bfacd0e..04109f4c33 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx @@ -26,10 +26,14 @@ import DeleteRoundedIcon from "@mui/icons-material/DeleteRounded"; import { cloneDeep } from "lodash"; import { FormValue } from "./views/FieldForm"; -import { FieldSettingsOptions } from "../../../../../../shell/services/types"; +import { + FieldSettingsOptions, + IntegrationFieldConfig, +} from "../../../../../../shell/services/types"; import { convertDropdownValue } from "../../utils"; import { withCursorPosition } from "../../../../../../shell/components/withCursorPosition"; import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/currencies"; +import IntegrationFieldConfigure from "../../../../../../shell/components/FieldTypeIntegration/IntegrationFieldConfigure"; const TextFieldWithCursorPosition = withCursorPosition(TextField); @@ -57,14 +61,18 @@ export type FieldNames = | "maxValue" | "currency" | "fileExtensions" - | "fileExtensionsErrorMessage"; + | "fileExtensionsErrorMessage" + | "integrationFieldConfig"; + type FieldType = | "input" | "checkbox" | "dropdown" | "autocomplete" | "options" - | "toggle_options"; + | "toggle_options" + | "config"; + type InputType = "text" | "number"; export interface InputField { name: FieldNames; @@ -107,6 +115,8 @@ type FieldFormInputProps = { dropdownOptions?: DropdownOptions[] | Currency[]; disabled?: boolean; autocompleteConfig?: AutocompleteConfig; + integrationFieldConfig?: IntegrationFieldConfig; + isUpdateField?: boolean; } & Pick< AutocompleteProps, "renderOption" | "filterOptions" @@ -121,6 +131,8 @@ export const FieldFormInput = ({ renderOption, filterOptions, autocompleteConfig, + integrationFieldConfig, + isUpdateField, }: FieldFormInputProps) => { const options = fieldConfig.type === "options" || @@ -431,6 +443,16 @@ export const FieldFormInput = ({ )} )} + {fieldConfig.type === "config" && ( + + onDataChange({ inputName: fieldConfig.name, value }) + } + isUpdate={isUpdateField} + error={errorMsg} + /> + )} ); }; @@ -486,7 +508,7 @@ const KeyValueInput = ({ required fullWidth placeholder="Enter Label" - value={optionValue} + value={optionValue || ""} onChange={(e: React.ChangeEvent) => { handleDataChanged("value", e.target?.value); }} @@ -504,7 +526,7 @@ const KeyValueInput = ({ required fullWidth placeholder="Enter Value" - value={optionKey} + value={optionKey || ""} onChange={(e: React.ChangeEvent) => { handleDataChanged("key", e.target?.value); }} diff --git a/src/apps/schema/src/app/components/AddFieldModal/InputRange.tsx b/src/apps/schema/src/app/components/AddFieldModal/InputRange.tsx index 738d9f6dce..33fec33384 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/InputRange.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/InputRange.tsx @@ -9,8 +9,10 @@ import { } from "@mui/material"; import { Errors } from "./views/FieldForm"; import { FieldTypeNumber } from "../../../../../../shell/components/FieldTypeNumber"; +import { FieldType } from "../configs"; type InputRangeProps = { + type?: "number" | "currency" | "integration"; onChange: ({ inputName, value, @@ -21,13 +23,18 @@ type InputRangeProps = { minValue: number | null; maxValue: number | null; errors: Errors; + primaryText?: string | null; + secondaryText?: string | null; }; export const InputRange = ({ + type = "number", onChange, minValue, maxValue, errors, + primaryText = null, + secondaryText = null, }: InputRangeProps) => { return ( @@ -54,7 +61,7 @@ export const InputRange = ({ label={ - Limit Input Range + {primaryText || "Limit Input Range"} - Set a minimum and/or maximum allowed value + {secondaryText || "Set a minimum and/or maximum allowed value"} } diff --git a/src/apps/schema/src/app/components/AddFieldModal/index.tsx b/src/apps/schema/src/app/components/AddFieldModal/index.tsx index 44584420aa..6d988a1841 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/index.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/index.tsx @@ -1,7 +1,6 @@ import { useState, useMemo, useEffect } from "react"; -import { useParams, useLocation } from "react-router"; +import { useParams } from "react-router"; import { Dialog } from "@mui/material"; -import { theme } from "@zesty-io/material"; import { FieldSelection } from "./views/FieldSelection"; import { FieldForm } from "./views/FieldForm"; @@ -60,13 +59,18 @@ export const AddFieldModal = ({ onModalClose, mode, sortIndex }: Props) => { sx={{ my: "20px", }} - PaperProps={{ - sx: { - width: viewMode === "fields_list" ? "900px" : "640px", - maxWidth: "100%", - maxHeight: "min(100%, 1000px)", - minHeight: "680px", - m: 0, + slotProps={{ + paper: { + sx: { + width: viewMode === "fields_list" ? "900px" : "640px", + maxWidth: "100%", + maxHeight: "min(100%, 1000px)", + minHeight: "680px", + m: 0, + "&:has(.IntegrationConfigForm)": { + visibility: "hidden", + }, + }, }, }} > diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx index e4728796df..a5be9e4768 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx @@ -14,6 +14,7 @@ import { Grid, ListItem, InputAdornment, + Fade, } from "@mui/material"; import { isEmpty } from "lodash"; import CloseIcon from "@mui/icons-material/Close"; @@ -53,6 +54,7 @@ import { ContentModelFieldValue, FieldSettingsOptions, ContentModelFieldDataType, + IntegrationFieldConfig, } from "../../../../../../../shell/services/types"; import { FIELD_COPY_CONFIG, TYPE_TEXT, FORM_CONFIG } from "../../configs"; import { Learn } from "../Learn"; @@ -230,6 +232,9 @@ export const FieldForm = ({ formFields[field.name] = fieldData.settings?.[field.name] ?? null; } else if (field.name === "fileExtensionsErrorMessage") { formFields[field.name] = fieldData.settings?.[field.name] ?? null; + } else if (field.name === "integrationFieldConfig") { + formFields["integrationFieldConfig"] = + fieldData?.["integrationFieldConfig"] ?? null; } else { formFields[field.name] = fieldData[field.name] as FormValue; } @@ -248,6 +253,16 @@ export const FieldForm = ({ formFields[field.name] = [{ 0: "No" }, { 1: "Yes" }]; } else if (field.name === "defaultValue" && type === "number") { formFields[field.name] = 0; + } else if ( + field.type === "config" && + field.name === "integrationFieldConfig" + ) { + formFields["integrationFieldConfig"] = { + endpoint: "", + headers: null, + type: null, + keyPaths: null, + }; } else { if ( field.name === "defaultValue" || @@ -274,7 +289,6 @@ export const FieldForm = ({ } } }); - setFormData(formFields); setErrors(errors); }, [type, fieldData, mediaFoldersOptions.length]); @@ -416,12 +430,13 @@ export const FieldForm = ({ newErrorsObj[inputName] = "This field is required"; } - if ( - inputName === "fileExtensionsErrorMessage" && - formData.fileExtensions !== null && - formData.fileExtensionsErrorMessage === "" - ) { - newErrorsObj[inputName] = "This field is required"; + if (inputName === "integrationFieldConfig") { + const intField = + formData?.integrationFieldConfig as IntegrationFieldConfig; + + if (!intField?.endpoint || !intField?.type || !intField?.keyPaths) { + newErrorsObj[inputName] = "Incomplete API Configuration"; + } } if ( @@ -439,6 +454,7 @@ export const FieldForm = ({ "currency", "fileExtensions", "fileExtensionsErrorMessage", + "integrationFieldConfig", ].includes(inputName) ) { const { maxLength, label, validate } = FORM_CONFIG[type].details.find( @@ -515,6 +531,7 @@ export const FieldForm = ({ const handleSubmitForm = () => { setIsSubmitClicked(true); + const hasErrors = Object.values(errors) .flat(2) .some((error) => error.length); @@ -624,6 +641,11 @@ export const FieldForm = ({ body.settings.options = optionsObject; } + if (type === "integration") { + body.integrationFieldConfig = + formData?.integrationFieldConfig as IntegrationFieldConfig; + } + if (isUpdateField) { const updateBody: ContentModelField = { ...fieldData, @@ -833,12 +855,21 @@ export const FieldForm = ({ let renderOption: any; let filterOptions: any; let autocompleteConfig: AutocompleteConfig = {}; + let integrationFieldConfig: IntegrationFieldConfig = null; if (fieldConfig.name === "relatedModelZUID") { dropdownOptions = modelsOptions; disabled = isLoadingModels; } + if (fieldConfig.name === "integrationFieldConfig") { + integrationFieldConfig = isUpdateField + ? fieldData?.integrationFieldConfig + : (formData[ + "integrationFieldConfig" + ] as IntegrationFieldConfig); + } + if (fieldConfig.name === "relatedFieldZUID") { dropdownOptions = fieldsOptions; disabled = isFetchingSelectedModelFields; @@ -924,6 +955,8 @@ export const FieldForm = ({ renderOption={renderOption} filterOptions={filterOptions} autocompleteConfig={autocompleteConfig} + integrationFieldConfig={integrationFieldConfig} + isUpdateField={isUpdateField} /> ); })} diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx index ee388a338b..c89634848d 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx @@ -48,27 +48,29 @@ export const Rules = ({ return ( - { - onFieldDataChanged({ inputName: "defaultValue", value }); - }} - isDefaultValueEnabled={isDefaultValueEnabled} - setIsDefaultValueEnabled={setIsDefaultValueEnabled} - error={isSubmitClicked && (errors["defaultValue"] as string)} - mediaRules={{ - limit: formData["limit"], - group_id: formData["group_id"], - }} - relationshipFields={{ - relatedModelZUID: formData["relatedModelZUID"] as string, - relatedFieldZUID: formData["relatedFieldZUID"] as string, - }} - options={formData["options"] as FieldSettingsOptions[]} - currency={(formData["currency"] as string) || "USD"} - fieldLabel={formData["label"] as string} - /> + {type !== "integration" && ( + { + onFieldDataChanged({ inputName: "defaultValue", value }); + }} + isDefaultValueEnabled={isDefaultValueEnabled} + setIsDefaultValueEnabled={setIsDefaultValueEnabled} + error={isSubmitClicked && (errors["defaultValue"] as string)} + mediaRules={{ + limit: formData["limit"], + group_id: formData["group_id"], + }} + relationshipFields={{ + relatedModelZUID: formData["relatedModelZUID"] as string, + relatedFieldZUID: formData["relatedFieldZUID"] as string, + }} + options={formData["options"] as FieldSettingsOptions[]} + currency={(formData["currency"] as string) || "USD"} + fieldLabel={formData["label"] as string} + /> + )} {type === "images" && ( )} - {(type === "number" || type === "currency") && ( + {["number", "currency", "integration"].includes(type) && ( )} diff --git a/src/apps/schema/src/app/components/Field/FieldIcon.tsx b/src/apps/schema/src/app/components/Field/FieldIcon.tsx index 3326cc4fee..cc4fb57748 100644 --- a/src/apps/schema/src/app/components/Field/FieldIcon.tsx +++ b/src/apps/schema/src/app/components/Field/FieldIcon.tsx @@ -17,7 +17,7 @@ import ColorLensRounded from "@mui/icons-material/ColorLensRounded"; import FormatListNumberedRounded from "@mui/icons-material/FormatListNumberedRounded"; import { Markdown, OneToOne, Block } from "@zesty-io/material"; import { Box, SvgIcon } from "@mui/material"; - +import DataObjectIcon from "@mui/icons-material/DataObject"; type Icons = { [key: string]: { icon: SvgIconComponent; @@ -126,6 +126,11 @@ const icons: Icons = { backgroundColor: "grey.100", borderColor: "grey.700", }, + integration: { + icon: DataObjectIcon, + backgroundColor: "purple.50", + borderColor: "purple.700", + }, }; interface Props { diff --git a/src/apps/schema/src/app/components/configs.ts b/src/apps/schema/src/app/components/configs.ts index 4357f544fe..e91690f085 100644 --- a/src/apps/schema/src/app/components/configs.ts +++ b/src/apps/schema/src/app/components/configs.ts @@ -24,7 +24,8 @@ export type FieldType = | "fontawesome" | "wysiwyg_advanced" | "article_writer" - | "block_selector"; // TODO: Will need to confirm if this type is already supported by the api + | "block_selector" // TODO: Will need to confirm if this type is already supported by the api + | "integration"; interface FieldListData { type: FieldType; name: string; @@ -327,6 +328,24 @@ const FIELD_COPY_CONFIG: { [key: string]: FieldListData[] } = { "You can change the default sort number with the plus and minus buttons in the content item view, as well as in the table view.", subHeaderText: "Use to add order to content items", }, + { + type: "integration", + name: "Integration", + shortDescription: "Fetch and store data from APIs", + description: + "This field allows users to fetch data from a JSON API, select entries and then add them to a content item. The data remains static until reselected. Ensuring controlled updates.", + commonUses: [ + "Stats - Fetch external stats", + "3rd Party Integration - Pull details from an external app", + "Forms - Import external form submissions", + "Import Content from another Zesty instance", + "External CMS - Display content from an external CMS", + "Spreadsheets - Pull spreadsheet data", + ], + proTip: + "The data is stored as a JSON object and can be accessed headlessly or with Parsley for dynamic rendering in templates. ", + subHeaderText: "Fetch and store data from APIs", + }, { type: "uuid", name: "UUID", @@ -364,6 +383,7 @@ const TYPE_TEXT: Record = { wysiwyg_basic: "WYSIWYG", yes_no: "Boolean", block_selector: "Block Selector", + integration: "Integration", }; const COMMON_FIELDS: InputField[] = [ @@ -717,6 +737,22 @@ const FORM_CONFIG: Record = { details: [...COMMON_FIELDS], rules: [], }, + integration: { + details: [ + ...COMMON_FIELDS.slice(0, 4), + { + name: "integrationFieldConfig", + type: "config", + label: "API URL", + required: true, + gridSize: 12, + maxLength: 150, + }, + + ...COMMON_FIELDS.slice(4), + ], + rules: [...INPUT_RANGE_RULES], + }, }; const SYSTEM_FIELDS: readonly SystemField[] = [ diff --git a/src/apps/schema/src/app/utils/index.ts b/src/apps/schema/src/app/utils/index.ts index f43813cb88..8068b663c8 100644 --- a/src/apps/schema/src/app/utils/index.ts +++ b/src/apps/schema/src/app/utils/index.ts @@ -162,6 +162,7 @@ export const getCategory = (type: string) => { case "color": case "sort": case "uuid": + case "integration": category = "options"; break; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/ConfigureDisplayOptions.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/ConfigureDisplayOptions.tsx new file mode 100644 index 0000000000..542643d072 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/ConfigureDisplayOptions.tsx @@ -0,0 +1,449 @@ +import { useEffect, useState, useRef } from "react"; +import { + Button, + DialogActions, + DialogContent, + DialogTitle, + Box, + Divider, + List, + ListItem, + Paper, + Stack, + Typography, + IconButton, +} from "@mui/material"; +import { + CheckRounded, + Settings, + DragIndicatorRounded, + MoreHoriz, + DeleteRounded, + AddRounded, +} from "@mui/icons-material"; + +import { FormWrapper, FieldWrapper } from "../components/Wrappers"; +import { DISPLAY_OPTIONS_CONFIG, ConfigProps } from "../configs"; +import { getKeyValue } from "../utils"; +import { IntegrationKeyPaths, IntegrationTypes } from "../../../services/types"; +import KeyPathSelector from "./KeyPathSelector"; +import DisplayCard from "../components/DisplayCard"; + +const getObjectKeyPaths = (obj: T, prefix = ""): string[] => { + const result: string[] = []; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const currentKey = prefix ? `${prefix}.${key}` : key; + const value = (obj as Record)[key]; + + if (typeof value === "object" && value !== null) { + if (Array.isArray(value)) { + value.forEach((arrayElement, index) => { + const arrayKey = `${currentKey}[${index}]`; + if (typeof arrayElement === "object" && arrayElement !== null) { + result.push(...getObjectKeyPaths(arrayElement, arrayKey)); + } else { + result.push(arrayKey); + } + }); + } else { + result.push(...getObjectKeyPaths(value, currentKey)); + } + } else { + result.push(currentKey); + } + } + } + + return result; +}; + +const getAllArrayKeyPaths = ( + obj: T, + prefix = "" +): string[] => { + const result: string[] = []; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const currentKey = prefix ? `${prefix}.${key}` : key; + const value = (obj as Record)[key]; + + if (Array.isArray(value)) { + if (value.length > 0 && typeof value[0] === "object") { + result.push(currentKey); + } + + value.forEach((item, index) => { + if (typeof item === "object" && item !== null) { + result.push( + ...getAllArrayKeyPaths(item, `${currentKey}[${index}]`) + ); + } + }); + } else if (typeof value === "object" && value !== null) { + result.push(...getAllArrayKeyPaths(value, currentKey)); + } + } + } + + return result; +}; + +const ConfigureDisplayOptions = ({ + type, + keyPaths, + setKeyPaths, + apiData, + closeForm, + setActiveStep, + onSave, +}: { + type: IntegrationTypes | null; + keyPaths: IntegrationKeyPaths | null; + setKeyPaths: (keyPaths: IntegrationKeyPaths) => void; + apiData: any; + closeForm?: () => void; + setActiveStep?: (step: number) => void; + onSave: () => void; +}) => { + const lastDetailRef = useRef(null); + const [apiPathOptions, setApiPathOptions] = useState([]); + const [rootPathOptions, setRootPathOptions] = useState([]); + const [rootPath, setRootPath] = useState(keyPaths?.rootPath || ""); + const [rootData, setRootData] = useState(null); + + const [rootPathData, setRootPathData] = useState({ + heading: keyPaths?.heading || null, + subHeading: keyPaths?.subHeading || null, + thumbnail: keyPaths?.thumbnail || null, + detail: keyPaths?.detail || null, + }); + const [detailsPathData, setDetailsPathData] = useState( + type !== "details" ? null : keyPaths?.details || [""] + ); + const [isCompleted, setIsCompleted] = useState(false); + + const handleSave = () => { + onSave(); + closeForm?.(); + }; + + const handleRemoveDetail = (index: number) => { + if (detailsPathData.length === 1) { + setDetailsPathData([""]); + lastDetailRef.current?.focus(); + return; + } + setDetailsPathData((prev) => prev.filter((_, i) => i !== index)); + }; + + useEffect(() => { + if (!apiData || rootData) return; + + if (rootPath) { + const dataRoot = getKeyValue(apiData, rootPath)[0]; + const optionsRaw = getObjectKeyPaths(dataRoot); + setRootData(dataRoot); + setRootPathOptions(optionsRaw); + return; + } + + const rootIsArray = Array.isArray(apiData); + if (rootIsArray) { + const dataRoot = apiData?.[0]; + const rootOptions = getObjectKeyPaths(dataRoot); + setRootData(dataRoot); + setRootPathOptions(rootOptions); + return; + } + + const apiOptions = getAllArrayKeyPaths(apiData); + setRootData(null); + setApiPathOptions(apiOptions); + }, [apiData, rootPath, rootData]); + + const displayConfig = DISPLAY_OPTIONS_CONFIG?.[type] || []; + + useEffect(() => { + const inputs = displayConfig?.map((item) => item?.name); + + const inputStatus = inputs?.map((inputName) => { + if (inputName === "details") { + return !detailsPathData?.filter((item) => !item)?.length; + } + return !!rootPathData?.[inputName as keyof typeof rootPathData]; + }); + + const completed = inputStatus?.every((status) => !!status); + + if (completed) { + setKeyPaths({ + rootPath, + ...rootPathData, + details: detailsPathData, + }); + } + setIsCompleted(completed); + }, [displayConfig, rootPathData, detailsPathData, rootPath]); + + return ( + + + + + + + Configure Display Options + + + Select which fields to display to content editors when they are + editing items + + + + + + + + + + + Select Keys to Display in Item + + + These can be re-configured later + + + + {apiPathOptions.length > 0 && ( + <> + + { + const rootDataRaw = getKeyValue(apiData, value)[0]; + const rootPathOptionsRaw = getObjectKeyPaths(rootDataRaw); + setRootData(rootDataRaw); + setRootPathOptions(rootPathOptionsRaw); + setRootPath(value); + }} + data={apiData} + /> + + + + )} + + + {rootPathOptions.length > 0 && + displayConfig.map((config: ConfigProps) => ( + + {config.type === "option" ? ( + + + {detailsPathData.map((item, index) => ( + + { + setDetailsPathData((prev) => [ + ...prev.slice(0, index), + value, + ...prev.slice(index + 1), + ]); + }} + inputRef={ + index === detailsPathData.length - 1 + ? lastDetailRef + : null + } + /> + handleRemoveDetail(index)} + > + + + + ))} + + + + ) : ( + + setRootPathData((prev) => ({ + ...prev, + [config.name]: value, + })) + } + data={rootData} + /> + )} + + ))} + + + + + + Item Preview + + + How the items will appear to content editors + + + + + ({ + key: keyPath || "", + value: !keyPath ? "" : getKeyValue(rootData, keyPath), + }))} + showPlayIcon={false} + /> + + + + + + + + + + + + ); +}; + +export default ConfigureDisplayOptions; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/ConnectToApi.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/ConnectToApi.tsx new file mode 100644 index 0000000000..11861c1e60 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/ConnectToApi.tsx @@ -0,0 +1,361 @@ +import { useCallback, useState } from "react"; +import { + Button, + Box, + Typography, + DialogContent, + DialogActions, + DialogTitle, + TextField, + Grid, + Link, + Divider, + Paper, +} from "@mui/material"; +import DataObjectRoundedIcon from "@mui/icons-material/DataObjectRounded"; +import MenuBookRoundedIcon from "@mui/icons-material/MenuBookRounded"; +import LinkRoundedIcon from "@mui/icons-material/LinkRounded"; +import CircularProgress from "@mui/material/CircularProgress"; +import StopRoundedIcon from "@mui/icons-material/StopRounded"; +import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; +import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded"; +import AutorenewRoundedIcon from "@mui/icons-material/AutorenewRounded"; +import { FormWrapper, FieldWrapper } from "../components/Wrappers"; +import { IntegrationRequestHeaders } from "../../../services/types"; +import { validateUrl } from "../../../../utility/validateUrl"; +import useIntegrationField from "../useIntegrationField"; + +const CONNECTION_STATUSES: { + [key: string]: { + icon: React.ReactNode; + title: string; + subTitle: string; + buttonLabel: string; + buttonIcon: React.ReactNode; + variant: "contained" | "outlined"; + color: "primary" | "inherit"; + }; +} = { + connecting: { + icon: , + title: "Connecting to API Endpoint", + subTitle: "Please wait while we establish a secure connection", + buttonLabel: "Stop", + buttonIcon: , + variant: "outlined", + color: "inherit", + }, + success: { + icon: , + title: "Connection Successful", + subTitle: "Your API is now securely linked and ready to be used.", + buttonLabel: "Next", + buttonIcon: , + variant: "contained", + color: "primary", + }, + failed: { + icon: ( + + ), + title: "Connection Failed", + subTitle: + "We couldn't connect to the API endpoint you entered. This may be due to an unexpected structure, a missing or invalid URL, or incorrect custom integrationHeaders.", + buttonLabel: "Try Again", + buttonIcon: , + variant: "contained", + color: "primary", + }, +}; + +const ConnectToApi = ({ + activeStep, + endpoint, + setEndpoint, + headers, + setHeaders, + setApiData, + setActiveStep, + closeForm, +}: { + activeStep: number; + endpoint: string; + setEndpoint: (endpoint: string) => void; + headers: IntegrationRequestHeaders; + setHeaders: (headers: IntegrationRequestHeaders | null) => void; + setApiData: (data: any) => void; + setActiveStep: (step: number) => void; + closeForm?: () => void; +}) => { + const { data, status, fetchApiData } = useIntegrationField(); + + const [isValidUrl, setIsValidUrl] = useState(true); + const [reqAborted, setReqAborted] = useState(false); + + const [endpointLocal, setEndpointLocal] = useState(endpoint || ""); + + const [headersLocal, setHeadersLocal] = useState< + { key: string; value: string }[] | null + >( + !headers + ? [] + : Object?.entries(headers).map(([key, value]) => ({ + key, + value, + })) + ); + + const handleNext = () => { + const reqHeaders = !headersLocal?.length + ? null + : headersLocal?.reduce((acc: any, obj: any) => { + acc[obj.key] = obj.value; + return acc; + }, {}); + + setApiData(data); + setHeaders(reqHeaders); + setEndpoint(endpointLocal); + setReqAborted(false); + setActiveStep(activeStep + 1); + }; + const handleAbort = () => { + setReqAborted(true); + setActiveStep(0); + }; + + const handleApiConnect = useCallback(() => { + setReqAborted(false); + setApiData(null); + + const headersWithKeys = headersLocal.filter((i) => !!i?.key); + + const reqHeaders = !headersWithKeys?.length + ? null + : headersWithKeys?.reduce((acc: any, obj: any) => { + acc[obj.key] = obj.value; + return acc; + }, {}); + + fetchApiData(endpointLocal, reqHeaders); + }, [endpointLocal, headersLocal]); + + return ( + + + + + Connect to API + + + Establish a connection to an endpoint for users to select items from + + + + + Learn about endpoint structures we accept + + + + + + { + const validUrl = !!e.target.value && validateUrl(e.target.value); + setIsValidUrl(validUrl); + setEndpointLocal(e.target.value); + }} + slotProps={{ + input: { + startAdornment: ( + + ), + }, + }} + error={!isValidUrl} + helperText={ + !isValidUrl + ? "Please enter a valid URL. e.g. https://api.example.org/items.json" + : "" + } + /> + + + + + {[...new Array(5)].map((_, i) => ( + + + { + const newHeaders = headersLocal + ? [...headersLocal] + : null; + newHeaders[i] = { + ...newHeaders[i], + key: e.target.value, + value: headersLocal?.[i]?.value || "", + }; + setHeadersLocal(newHeaders); + }} + /> + + + { + const newHeaders = headersLocal ? [...headersLocal] : []; + newHeaders[i] = { + ...newHeaders[i], + value: e.target.value || "", + }; + setHeadersLocal(newHeaders); + }} + /> + + + ))} + + + + + + + + {!!status && !reqAborted && ( + + {CONNECTION_STATUSES?.[status]?.icon || null} + + + {CONNECTION_STATUSES[status].title} + + + {CONNECTION_STATUSES[status].subTitle} + + + + + )} + + ); +}; +export default ConnectToApi; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/DisplayOption.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/DisplayOption.tsx new file mode 100644 index 0000000000..958584eb38 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/DisplayOption.tsx @@ -0,0 +1,133 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import CardActionArea from "@mui/material/CardActionArea"; + +import DragIndicatorRoundedIcon from "@mui/icons-material/DragIndicatorRounded"; +import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; +import { DisplayOptionCardProps } from "../configs"; +import DisplayCard from "../components/DisplayCard"; +import { alpha, Avatar, Grid, Paper, Stack } from "@mui/material"; + +const DisplayOption = ({ + title, + description, + type, + card, + disabled = false, + isSelected = false, + onSelect, +}: Partial) => { + return ( + + ({ + height: "100%", + "&[data-selected]": { + outline: "1px solid", + outlineColor: "primary.main", + outlineOffset: "-1px", + pointerEvents: "none", + "& .left-grid": { + outline: "1px solid", + outlineColor: "primary.main", + outlineOffset: "-1px", + backgroundColor: alpha(theme.palette.primary.main, 0.04), + }, + }, + "&:disabled": { + opacity: 0.5, + }, + })} + > + + + + + {["shopify", "youtube", "mux", "classy"]?.includes(type) && ( + + )} + + {title} + + + {description} + + + + + + + + + + + + + + + ); +}; + +export default DisplayOption; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/KeyPathSelector.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/KeyPathSelector.tsx new file mode 100644 index 0000000000..f2a3f95296 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/KeyPathSelector.tsx @@ -0,0 +1,130 @@ +import { RefObject } from "react"; +import { Box, Typography, Autocomplete, Paper, TextField } from "@mui/material"; + +import { COLOR_MAP } from "../configs"; +import { getKeyValue } from "../utils"; +import { IntegrationTypes } from "../../../services/types"; +import { validateUrl } from "../../../../utility/validateUrl"; + +const KeyPathSelector = ({ + value, + onChange, + options, + placeholder = "", + optionsDescription = "", + data, + inputRef, + restrictedTypes = [], + name, +}: { + value: string; + onChange: (value: string) => void; + options: string[]; + placeholder?: string; + optionsDescription?: string; + data?: any; + inputRef?: RefObject; + type?: IntegrationTypes; + restrictedTypes?: string[]; + name?: string; +}) => { + const filteredOptions = restrictedTypes.length + ? options.filter((option) => { + const optionValue = getKeyValue(data, option); + const valueType = typeof optionValue; + return !restrictedTypes.includes(valueType); + }) + : options; + + const getValueType = (value: any): string => { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; + }; + + return ( + onChange(value || "")} + slots={{ + paper: (props) => ( + + {optionsDescription && ( + + {optionsDescription} + + )} + {props?.children} + + ), + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const optionValue = data ? getKeyValue(data, option) : null; + const valueType = getValueType(optionValue); + const isUrl = + valueType === "string" && validateUrl(optionValue as string); + const typeColor = + COLOR_MAP[valueType as keyof typeof COLOR_MAP] || COLOR_MAP.default; + + const { key, ...otherProps } = props; + + return ( +
  • + + + {`${option}: `} + + {valueType === "string" + ? `"${optionValue}"` + : JSON.stringify(optionValue, null, 3)} + + + + + {valueType} + + +
  • + ); + }} + /> + ); +}; + +export default KeyPathSelector; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/SelectDisplayOptions.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/SelectDisplayOptions.tsx new file mode 100644 index 0000000000..c94759bd47 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/SelectDisplayOptions.tsx @@ -0,0 +1,309 @@ +import { useEffect, useState } from "react"; +import Button from "@mui/material/Button"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import { Box, Stack, Typography } from "@mui/material"; +import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; +import IconButton from "@mui/material/IconButton"; + +import { IntegrationTypes } from "../../../services/types"; +import { GENERIC_DISPLAY_TYPES, SPECIAL_DISPLAY_TYPES } from "../configs"; +import { FormWrapper } from "../components/Wrappers"; +import DisplayOption from "./DisplayOption"; + +type SelectDisplayOptionsProps = { + activeStep: number; + setActiveStep: (step: number) => void; + endpoint: string | null; + type: IntegrationTypes | null; + setType: (type: IntegrationTypes | null) => void; + closeForm: () => void; + resetKeyPaths: () => void; +}; + +const SelectDisplayOptions = ({ + activeStep, + setActiveStep, + endpoint, + type, + setType, + closeForm, + resetKeyPaths, +}: SelectDisplayOptionsProps) => { + const [recommendedType, setRecommendedType] = + useState(null); + + const [displayType, setDisplayType] = useState( + type || null + ); + + const recommendedOption = SPECIAL_DISPLAY_TYPES.filter( + (option) => option.type === recommendedType + ); + + const disabledOptions = SPECIAL_DISPLAY_TYPES.filter( + (option) => option.type !== recommendedType + ); + + const handleNext = () => { + if (displayType !== type) { + resetKeyPaths(); + } + setType(displayType); + setActiveStep(activeStep + 1); + }; + + useEffect(() => { + const endpointTypes: { keyword: string; type: IntegrationTypes }[] = [ + { keyword: "mux", type: "mux" }, + { keyword: "youtube", type: "youtube" }, + { keyword: "shopify", type: "shopify" }, + { keyword: "classy", type: "classy" }, + ]; + + const apiUrl = new URL(endpoint || ""); + + const matchedType: IntegrationTypes | null = + endpointTypes.find(({ keyword }) => apiUrl?.origin?.includes(keyword)) + ?.type || null; + + setRecommendedType(matchedType); + if (!!displayType) return; + setDisplayType(matchedType); + }, [endpoint, displayType]); + + return ( + + + + + + Select a Display Type + + + This can be re-configured later + + + + + + + + + + {!!recommendedOption?.length && ( + + + RECOMMENDED + + + {recommendedOption?.map((item) => ( + { + setDisplayType(item?.type); + }} + /> + ))} + + + )} + + + + {!!recommendedOption?.length ? "OTHER OPTIONS" : "OPTIONS"} + + + {GENERIC_DISPLAY_TYPES?.map((item) => ( + { + setDisplayType(item?.type); + }} + /> + ))} + + + + + + {!!recommendedOption?.length ? "NOT AVAILABLE" : "OTHER OPTIONS"} + + + {disabledOptions?.map((item, index) => ( + { + setDisplayType(item?.type); + }} + /> + ))} + + + + + + + + + + ); +}; + +export default SelectDisplayOptions; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/index.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/index.tsx new file mode 100644 index 0000000000..e7bac2aa77 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldConfigure/index.tsx @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useState, useRef } from "react"; +import { + Box, + Button, + Paper, + Typography, + InputBase, + Dialog, +} from "@mui/material"; +import { LinkRounded, Autorenew } from "@mui/icons-material"; +import useIntegrationField from "../useIntegrationField"; +import { + IntegrationFieldConfig, + IntegrationKeyPaths, + IntegrationRequestHeaders, + IntegrationTypes, +} from "../../../services/types"; +import SelectDisplayOptions from "./SelectDisplayOptions"; +import ConnectToApi from "./ConnectToApi"; +import ConfigureDisplayOptions from "./ConfigureDisplayOptions"; + +type IntegrationFieldConfigProps = { + integrationFieldConfig: IntegrationFieldConfig; + onChange: (value: any) => void; + isLoading?: boolean; + error?: string | [string, string][] | null; + isUpdate?: boolean; +}; + +const IntegrationFieldConfigure = ({ + onChange, + integrationFieldConfig, + isLoading = false, + error = null, + isUpdate = false, +}: IntegrationFieldConfigProps) => { + const { data, status, fetchApiData } = useIntegrationField(); + const hasFetchedInitialData = useRef(false); + + const [isFormOpen, setIsFormOpen] = useState(false); + const [activeStep, setActiveStep] = useState(0); + const [endpoint, setEndpoint] = useState( + integrationFieldConfig?.endpoint || "" + ); + const [headers, setHeaders] = useState( + integrationFieldConfig?.headers || null + ); + const [type, setType] = useState( + integrationFieldConfig?.type || null + ); + const [keyPaths, setKeyPaths] = useState( + integrationFieldConfig?.keyPaths || null + ); + const [apiData, setApiData] = useState(null); + + const isConnected = + !!integrationFieldConfig?.endpoint && !!integrationFieldConfig?.type; + const isFetchingApiData = status === "connecting"; + + const onSave = useCallback(() => { + const newData = { + endpoint: endpoint, + headers: headers, + type: type, + keyPaths: keyPaths, + }; + onChange(newData); + setIsFormOpen(false); + }, [endpoint, headers, type, keyPaths, onChange]); + + const onClose = useCallback(() => { + setIsFormOpen(false); + }, []); + + const onOpen = useCallback(() => { + setIsFormOpen(true); + setActiveStep(!isUpdate ? 0 : 1); + }, [isUpdate]); + + useEffect(() => { + if ( + !isUpdate || + !endpoint || + isFetchingApiData || + !!apiData || + hasFetchedInitialData.current + ) + return; + + hasFetchedInitialData.current = true; + fetchApiData(endpoint, headers); + }, [isUpdate, endpoint, headers, isFetchingApiData, apiData, fetchApiData]); + + useEffect(() => { + setApiData(data); + }, [data]); + + const renderStep = useCallback(() => { + switch (activeStep) { + case 0: + return ( + + ); + case 1: + return ( + setKeyPaths(null)} + /> + ); + case 2: + return ( + + ); + default: + return null; + } + }, [ + activeStep, + endpoint, + headers, + type, + keyPaths, + apiData, + onClose, + fetchApiData, + onSave, + ]); + + return ( + + {isConnected ? ( + <> + + API Configuration Settings + + + + + API URL + + + + + + Display Items as + + + + + + ) : null} + + + {!!error && ( + + {error} + + )} + {isFormOpen && ( + + {renderStep()} + + )} + + ); +}; + +export default IntegrationFieldConfigure; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/Draggable.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/Draggable.tsx new file mode 100644 index 0000000000..ae02431f5e --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/Draggable.tsx @@ -0,0 +1,226 @@ +import React, { useRef, useState, type ReactElement } from "react"; +import { + Box, + Paper, + Typography, + IconButton, + Menu, + MenuItem, + Skeleton, +} from "@mui/material"; +import { useDrag, useDrop } from "react-dnd"; +import DragIndicatorRoundedIcon from "@mui/icons-material/DragIndicatorRounded"; +import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; +import ClearIcon from "@mui/icons-material/Clear"; +import DataObjectIcon from "@mui/icons-material/DataObject"; + +interface DragItem { + id: string; + index: number; +} + +interface DraggableProps { + id: string; + index: number; + moveItem: (from: number, to: number) => void; + loading?: boolean; + onDelete?: () => void; + onView?: () => void; + children: ReactElement; +} + +const Draggable: React.FC = ({ + id, + index, + moveItem, + loading, + onDelete, + onView, + children, +}) => { + const itemRef = useRef(null); + + const [{ isDragging }, drag, preview] = useDrag< + DragItem, + void, + { isDragging: boolean } + >({ + type: "card", + options: { + dropEffect: "copy", + }, + item: { id, index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + const [{ isOver }, drop] = useDrop( + () => ({ + accept: "card", + + collect: (monitor) => ({ + isOver: monitor.isOver(), + }), + drop: (item: DragItem) => { + const dragIndex = item?.index; + const hoverIndex = index; + if (dragIndex === hoverIndex) return; + moveItem(dragIndex, hoverIndex); + item.index = dragIndex; + }, + }), + [moveItem] + ); + + drop(preview(itemRef)); + + return ( + +
    drag(node)} + style={{ + width: "28px", + display: "flex", + justifyContent: "center", + alignItems: "center", + cursor: isDragging ? "grabbing" : "grab", + }} + > + {loading ? ( + + ) : ( + + )} +
    + + + {children} + + + + + +
    + ); +}; + +interface MoreOptionsProps { + disableMenu?: boolean; + onDelete?: () => void; + onView?: () => void; +} + +const MoreOptions: React.FC = ({ + disableMenu = false, + onDelete, + onView, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleMenuItemClick = (handler?: () => void) => { + handler?.(); + handleClose(); + }; + + return ( + + + + + + handleMenuItemClick(onView)} + className="moreOptionMenuItem-view" + disabled={!onView} + > + + + View Raw JSON + + + handleMenuItemClick(onDelete)} + className="moreOptionMenuItem-remove" + disabled={!onDelete} + > + + + Remove + + + + + ); +}; + +export default Draggable; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/ItemSelectionDialog.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/ItemSelectionDialog.tsx new file mode 100644 index 0000000000..f85a6ab935 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/ItemSelectionDialog.tsx @@ -0,0 +1,440 @@ +import { useRef, useState, ChangeEvent, useMemo } from "react"; +import { + alpha, + Box, + Button, + Checkbox, + Dialog, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + ListItem, + Paper, + Skeleton, + TextField, + Typography, +} from "@mui/material"; +import AutoSizer from "react-virtualized-auto-sizer"; +import { + List, + CellComponentProps as ListChildComponentProps, +} from "react-window"; +import { Check, Close, DataObject, Search } from "@mui/icons-material"; +import { + IntegrationFieldConfig, + IntegrationTypes, +} from "../../../services/types"; +import { + ApiDataProps, + ApiDataWithIdProps, + DISPLAY_OPTIONS_CONFIG, + LOADING_DATA, +} from "../configs"; +import { getKeyValue } from "../utils"; +import DisplayCard from "../components/DisplayCard"; +import { NoResults } from "../../../../apps/schema/src/app/components/NoResults"; +import JsonViewer from "../components/JsonViewer"; + +interface ItemSelectionDialogProps { + title: string; + loading: boolean; + maxItems?: number; + open: boolean; + onClose: () => void; + items: ApiDataWithIdProps[]; + value: ApiDataWithIdProps[]; + config: IntegrationFieldConfig; + onSave: (value: ApiDataWithIdProps[]) => void; +} + +const getItemRowHeight = ( + type: IntegrationTypes, + details?: string[] +): number => { + if (type === "simple") return 60; + if (type === "details" && details?.length > 2) + return 96 + (details.length - 1) * 20; + return 96; +}; + +type RenderRowDataProps = { + loading?: boolean; + type: IntegrationTypes; + items: ApiDataProps[]; + selectedItems: ApiDataProps[]; + keyPaths: any; + onSelect: (item: ApiDataProps) => void; + maxItems?: number; + onView: (item: ApiDataProps) => void; +}; + +type RenderRowProps = Omit & { + index?: number; + style?: React.CSSProperties; + data: RenderRowDataProps; +}; + +const RenderRow = ({ data, index, style }: RenderRowProps) => { + const { + loading = false, + type, + items, + selectedItems, + keyPaths, + onSelect, + maxItems, + onView, + } = data; + const item = items[index]; + const selectedIds = selectedItems.map((item) => item?._itemId); + const limitReached = selectedIds.length >= maxItems; + const isSelected = selectedIds.includes(item?._itemId); + + const pathData = { + heading: getKeyValue(item, keyPaths?.heading), + subHeading: getKeyValue(item, keyPaths?.subHeading), + thumbnail: getKeyValue(item, keyPaths?.thumbnail), + detail: getKeyValue(item, keyPaths?.detail), + details: + type !== "details" + ? null + : keyPaths?.details?.map((detailKey: string) => ({ + key: detailKey, + value: getKeyValue(item, detailKey), + })), + }; + + const borderRadius = { + borderStartStartRadius: index === 0 ? 8 : 0, + borderStartEndRadius: index === 0 ? 8 : 0, + borderEndEndRadius: index === items.length - 1 ? 8 : 0, + borderEndStartRadius: index === items.length - 1 ? 8 : 0, + }; + + return ( + + alpha(theme.palette.primary.main, 0.04), + boxShadow: (theme) => + `0px -2px 0px 0px ${theme.palette.primary.light} inset`, + }), + }} + > + + {loading ? ( + + ) : ( + onSelect(item)} + sx={{ color: "grey.500" }} + /> + )} + + + + + + onView(item)} + loading={loading} + loadingIndicator={ + + } + > + + + + + + ); +}; + +const ItemSelectionDialog = ({ + title, + loading, + maxItems, + open, + onClose, + items, + value, + config, + onSave, +}: ItemSelectionDialogProps) => { + const searchInputRef = useRef(null); + const drawerContainerRef = useRef(null); + const [selectedItems, setSelectedItems] = + useState(value); + const [searchTerm, setSearchTerm] = useState(""); + const [jsonViewData, setJsonViewData] = useState(null); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const itemHeight = getItemRowHeight(config?.type, config?.keyPaths?.details); + + const displayConfig = DISPLAY_OPTIONS_CONFIG?.[config?.type] || []; + const keyPaths = config?.keyPaths; + + const handleSelect = (item: ApiDataWithIdProps) => { + setSelectedItems((prev) => { + const ids = prev.map((i) => i._itemId); + return ids.includes(item?._itemId) + ? prev.filter((prevItem) => prevItem?._itemId !== item?._itemId) + : [...prev, item]; + }); + }; + + const handleSave = () => { + onSave(selectedItems); + onClose(); + }; + + const handleView = (data: ApiDataWithIdProps) => { + setJsonViewData(data); + setIsDrawerOpen(true); + }; + + const filteredItems = useMemo(() => { + if (loading) return LOADING_DATA; + if (!searchTerm) return items; + + const validKeys = displayConfig + .filter((item) => item?.name !== "thumbnail") + .map((item) => keyPaths?.[item?.name as keyof typeof keyPaths]) + .flat(); + + const filtered = items.filter((item) => { + const searchString = validKeys + ?.map((itemKey) => getKeyValue(item, itemKey)?.trim()) + .join("\n") + .toLowerCase(); + + return searchString.includes(searchTerm.toLowerCase()); + }); + + return filtered; + }, [loading, LOADING_DATA, items, searchTerm, keyPaths, displayConfig]); + + const listData: RenderRowDataProps = { + type: config?.type, + items: filteredItems, + selectedItems, + keyPaths: config?.keyPaths, + onSelect: handleSelect, + maxItems, + onView: handleView, + loading: loading, + }; + + return ( + + + + {!loading && selectedItems.length + ? `${selectedItems.length} Selected` + : `Select ${title}`} + + + + {!loading && selectedItems.length > 0 && ( + <> + + + + )} + + + + + + + + + ) => + setSearchTerm(e.target.value) + } + slotProps={{ + input: { + disabled: loading, + startAdornment: ( + + + + ), + }, + }} + /> + + + {searchTerm && !filteredItems.length && !loading ? ( + + + { + setSearchTerm(""); + searchInputRef.current?.focus(); + }} + searchTerm={searchTerm} + /> + + + ) : ( + + + {({ height, width }: { height: number; width: number }) => ( + + )} + + + )} + + setIsDrawerOpen(false)} + data={jsonViewData} + showCloseButton={false} + container={drawerContainerRef} + isSlider={true} + /> + + ); +}; + +export default ItemSelectionDialog; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/SelectedListItems.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/SelectedListItems.tsx new file mode 100644 index 0000000000..163fce4354 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/SelectedListItems.tsx @@ -0,0 +1,100 @@ +import { useCallback, useState } from "react"; +import { Box } from "@mui/material"; +import { ApiDataWithIdProps } from "../configs"; +import { IntegrationFieldConfig } from "../../../services/types"; +import Draggable from "./Draggable"; +import { getKeyValue } from "../utils"; +import DisplayCard from "../components/DisplayCard"; +import JsonViewer from "../components/JsonViewer"; + +const SelectedListItems = ({ + items, + config, + onChange, +}: { + items: ApiDataWithIdProps[]; + config: IntegrationFieldConfig; + onChange: (items: any[]) => void; +}) => { + const [jsonData, setJsonData] = useState(null); + const [jsonViewerIsOpen, setJsonViewerIsOpen] = useState(false); + const handleDelete = useCallback( + (id: string) => { + const newItems = items?.filter((item) => item?._itemId !== id); + onChange(newItems); + }, + [items, onChange] + ); + const viewJson = useCallback((data: any) => { + setJsonData(data); + setJsonViewerIsOpen(true); + }, []); + + const moveItem = useCallback( + (from: number, to: number) => { + const newItems = [...items]; + const [removed] = newItems.splice(from, 1); + newItems.splice(to, 0, removed); + + onChange(newItems); + }, + [items, onChange] + ); + + return ( + <> + + {items?.map((item, index) => { + return ( + handleDelete(item?._itemId)} + onView={() => viewJson(item)} + > + ({ + key: detailKey, + value: getKeyValue(item, detailKey), + })) + } + /> + + ); + })} + + setJsonViewerIsOpen(false)} + data={jsonData} + showCloseButton={true} + isSlider={false} + /> + + ); +}; + +export default SelectedListItems; diff --git a/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/index.tsx b/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/index.tsx new file mode 100644 index 0000000000..7efb6f186d --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/IntegrationFieldSelect/index.tsx @@ -0,0 +1,140 @@ +import { useEffect, useMemo, useState } from "react"; +import { Box, Button } from "@mui/material"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { ApiDataProps, ApiDataWithIdProps } from "../configs"; +import { + IntegrationFieldConfig, + IntegrationKeyPaths, +} from "../../../services/types"; +import AddIcon from "@mui/icons-material/Add"; +import ItemSelectionDialog from "./ItemSelectionDialog"; +import SelectedListItems from "./SelectedListItems"; +import { getKeyValue } from "../utils"; +import useIntegrationField from "../useIntegrationField"; + +const getItemId = (item: ApiDataProps, keyPaths: IntegrationKeyPaths) => { + const validValues = Object.values(keyPaths) + ?.filter((value) => { + if (Array.isArray(value)) return value?.length > 0; + return value !== ""; + }) + ?.flat(); + const idParts = validValues?.map((key) => { + const value = item?.[key] || ""; + return typeof value === "string" ? value?.replace(/\s+/g, "") : value; + }); + + return idParts?.join("_"); +}; + +type IntegrationFieldSelectProps = { + name: string; + label: string; + maxItems?: number; + value: ApiDataProps[]; + config: IntegrationFieldConfig; + onChange: (value: ApiDataProps[]) => void; +}; + +const IntegrationFieldSelect = ({ + name, + label, + maxItems, + value, + config, + onChange, +}: IntegrationFieldSelectProps) => { + const { data: apiData, status, fetchApiData } = useIntegrationField(); + + const [open, setOpen] = useState(false); + const [selectedItems, setSelectedItems] = useState( + value?.map((item) => ({ + ...item, + _itemId: getItemId(item, config?.keyPaths), + })) || [] + ); + + const isError = status === "failed"; + const isLoading = status === "connecting"; + + const launchSelector = () => { + fetchApiData(config?.endpoint, config?.headers); + setOpen(true); + }; + + const handleSave = (items: ApiDataWithIdProps[]) => { + // setSelectedItems(items); + onChange( + items?.map((item) => { + const { _itemId, ...restItems } = item; + return restItems; + }) + ); + }; + + const items: ApiDataWithIdProps[] = useMemo(() => { + if (isLoading || !apiData || isError) return []; + const data = + (!config?.keyPaths?.rootPath + ? apiData + : getKeyValue(apiData, config?.keyPaths?.rootPath)) || []; + + const itemWithId = !data?.length + ? [] + : data?.map((item: ApiDataProps) => ({ + ...item, + _itemId: getItemId(item, config?.keyPaths), + })); + + return itemWithId; + }, [apiData, isError, isLoading, config?.keyPaths]); + + useEffect(() => { + const newValue = + value?.map((item) => ({ + ...item, + _itemId: getItemId(item, config?.keyPaths), + })) || []; + setSelectedItems(newValue); + }, [value, setSelectedItems]); + + return ( + + + + + + + {open && ( + setOpen(false)} + value={selectedItems} + config={config} + onSave={handleSave} + /> + )} + + ); +}; + +export default IntegrationFieldSelect; diff --git a/src/shell/components/FieldTypeIntegration/components/DisplayCard.tsx b/src/shell/components/FieldTypeIntegration/components/DisplayCard.tsx new file mode 100644 index 0000000000..6759e1f941 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/components/DisplayCard.tsx @@ -0,0 +1,345 @@ +import { useState } from "react"; +import { + Avatar, + Box, + Card, + CardMedia, + Skeleton, + Typography, +} from "@mui/material"; +import PlayCircleIcon from "@mui/icons-material/PlayCircle"; +import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded"; +import VideoCallRoundedIcon from "@mui/icons-material/VideoCallRounded"; +import { IntegrationTypes } from "../../../services/types"; + +export type DisplayCardProps = { + type: IntegrationTypes; + heading: string; + subHeading?: string; + thumbnail?: string; + rootPath?: string | null; + detail?: string; + details?: Record[]; + mediaVariant?: "rounded" | "square"; + showPlayIcon?: boolean; + loading?: boolean; +}; + +const DisplayCard = ({ + type, + heading, + subHeading, + thumbnail, + detail, + details, + mediaVariant = "square", + showPlayIcon, + loading, +}: DisplayCardProps) => { + const [noImage, setNoImage] = useState(false); + const isVideoType = ["video", "youtube", "mux"].includes(type); + const isSpecialType = ["shopify", "youtube", "mux", "classy"].includes(type); + const withCardMedia = !["classy", "text", "simple", "details"].includes(type); + + const renderMediaIcon = () => { + if (!withCardMedia) return null; + + if (showPlayIcon) { + return ; + } + + if (isVideoType) { + return ; + } + return ( + + ); + }; + + const headingValue = loading ? ( + + ) : typeof heading === "boolean" ? ( + String(heading) + ) : ( + heading || "Add Heading" + ); + const subHeadingValue = loading ? ( + + ) : typeof subHeading === "boolean" ? ( + String(subHeading) + ) : ( + subHeading || "Add Subheading" + ); + const thumbnailValue = thumbnail || null; + const detailValue = loading ? ( + + ) : typeof detail === "boolean" ? ( + String(detail) + ) : ( + detail || "Add Detail" + ); + + const renderCard = () => { + if (type === "simple") { + return ( + + + {headingValue} + + + ); + } + + if (type === "details") { + return ( + + + {headingValue} + + + + {details?.map((item: Record, index: number) => ( + + + {loading ? ( + + ) : ( + item?.key || "+ Add Detail" + )} + + + {loading ? ( + + ) : typeof item?.value === "boolean" ? ( + String(item?.value) + ) : ( + item?.value || "" + )} + + + ))} + + + ); + } + + return ( + <> + {withCardMedia && ( + + {loading ? ( + + ) : ( + <> + {!!thumbnailValue && ( + setNoImage(true)} + onLoad={() => setNoImage(false)} + /> + )} + {(!thumbnailValue || + noImage || + (showPlayIcon && isVideoType)) && + renderMediaIcon()} + + )} + + )} + + + + {headingValue} + + {type === "shopify" && !!detail && ( + + {detailValue} + + )} + + + + {subHeadingValue} + + + {isSpecialType && ( + + {loading ? ( + + ) : ( + + {type.substring(0, 1).toUpperCase()} + + )} + + )} + + ); + }; + + return ( + + {renderCard()} + + ); +}; + +export default DisplayCard; diff --git a/src/shell/components/FieldTypeIntegration/components/JsonViewer.tsx b/src/shell/components/FieldTypeIntegration/components/JsonViewer.tsx new file mode 100644 index 0000000000..435916cbac --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/components/JsonViewer.tsx @@ -0,0 +1,198 @@ +import { + IconButton, + Typography, + Box, + Drawer, + Dialog, + DrawerProps, + DialogProps, +} from "@mui/material"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import MonacoEditor from "react-monaco-editor/lib/editor"; +import CloseIcon from "@mui/icons-material/Close"; + +const DRAWER_SLOT_PROPS: DrawerProps = { + anchor: "left", + hideBackdrop: true, + slotProps: { + root: { + sx: { + position: "absolute", + }, + }, + paper: { + sx: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + }, + }, + }, +}; + +const DIALOG_SLOT_PROPS: DialogProps = { + open: true, + fullWidth: true, + maxWidth: "md", + slotProps: { + paper: { + sx: { + width: "100%", + height: "calc(100vh - 40px)", + maxHeight: "1240px", + }, + }, + }, +}; + +export const sanitizeJsonData = (data: any, stringify: boolean = false) => { + try { + let newData = data; + + if ("_itemId" in data) { + const { _itemId, ...restData } = data; + newData = restData; + } + return !stringify ? newData : JSON.stringify(newData, null, 2); + } catch (error) { + return `Script Error: ${error?.message}`; + } +}; + +const JsonViewer = ({ + open, + data, + onClose, + isSlider = false, + showCloseButton = false, + container, +}: { + open?: boolean; + data: any; + onClose: () => void; + isSlider?: boolean; + showCloseButton?: boolean; + container?: React.RefObject; +}) => { + const sanitizedJson = sanitizeJsonData(data); + + const Component = isSlider ? Drawer : Dialog; + const componentProps = isSlider + ? { + open, + onClose, + container: container?.current, + ...DRAWER_SLOT_PROPS, + } + : { + onClose, + fullWidth: true, + ...DIALOG_SLOT_PROPS, + open, + }; + + return ( + + + {!showCloseButton && ( + + + + )} + + View JSON + + {!!showCloseButton && ( + + + + )} + + + + + + ); +}; + +export default JsonViewer; diff --git a/src/shell/components/FieldTypeIntegration/components/Wrappers.tsx b/src/shell/components/FieldTypeIntegration/components/Wrappers.tsx new file mode 100644 index 0000000000..ab5f71846e --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/components/Wrappers.tsx @@ -0,0 +1,111 @@ +import { Box, Paper, Tooltip, Typography } from "@mui/material"; + +import InfoIcon from "@mui/icons-material/Info"; +import { ReactNode } from "react"; + +export const FieldWrapper = ({ + name, + label, + description, + toolTip, + isRequired, + error, + children, +}: { + name?: string; + label?: string; + description?: string; + toolTip?: string; + isRequired?: boolean; + error?: string; + children: React.ReactNode; +}) => { + return ( + + + {label} + {isRequired && *} + {!!toolTip && ( + + + + + + )} + + {!!description && ( + + {description} + + )} + {children} + {!!error && ( + + {error} + + )} + + ); +}; + +export const FormWrapper = ({ + width, + height, + children, +}: { + width: string | number; + height: string | number; + children: ReactNode; +}) => { + return ( + + {children} + + ); +}; diff --git a/src/shell/components/FieldTypeIntegration/configs.ts b/src/shell/components/FieldTypeIntegration/configs.ts new file mode 100644 index 0000000000..1b0fd471c4 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/configs.ts @@ -0,0 +1,256 @@ +import { + IntegrationTypes, + IntegrationKeyPaths, + IntegrationFieldConfig, +} from "../../services/types"; + +export type FormTypes = "select" | "configure"; + +export type IntegrationDisplayProps = { + [key: string]: string; +}; + +export type ApiResponse = { + status: "success" | "error"; + data?: T; +}; + +export type ApiDataProps = Record; + +export type ApiDataWithIdProps = ApiDataProps & { _itemId: string }; + +export type ListItemDataProps = { + type: IntegrationTypes; + items: ApiDataWithIdProps[]; + selectedItems: ApiDataWithIdProps[]; + keyPaths: IntegrationKeyPaths; + onSelect: (item: ApiDataWithIdProps) => void; + maxItems?: number; + onDelete?: (id: string) => void; + onView?: (data: any) => void; +}; + +export type FieldTypeIntegrationProps = { + name: string; + label: string; + description?: string; + formType?: FormTypes; + required?: boolean; + value?: any | null; + onChange?: (value: any) => void; + error?: string | [string, string][] | null; + integrationFieldConfig?: IntegrationFieldConfig | null; + maxItems?: number | null; + isLoading?: boolean; + isUpdate?: boolean; +}; + +export type DisplayOptionCardProps = { + title: string; + description: string; + type: IntegrationTypes; + // card: IntegrationKeyPaths; + card: Omit & { + details?: { + key: string; + value: string | number; + }[]; + }; + disabled?: boolean; + disableMenu?: boolean; + isSelected?: boolean; + onSelect?: () => void; +}; + +export type KeyValueOption = { + keyPath: string; + value: any; +}; + +type ConfigTypes = "option" | "text"; + +export type ConfigProps = { + name: string; + label: string; + type: ConfigTypes; + isRequired?: boolean; + description?: string; + placeholder?: string; +}; + +export const COLOR_MAP = { + string: "green", + object: "purple", + array: "purple", + number: "red", + boolean: "blue", + default: "grey", + url: "pink", +}; + +export const GENERIC_DISPLAY_TYPES: DisplayOptionCardProps[] = [ + { + title: "Text Card", + description: "Display items with a heading and subheading", + type: "text", + card: { + heading: "Chugging through Sri Lanka's tea plantations", + subHeading: "The beautiful train from Kandy to Ella", + }, + }, + { + title: "Image Card", + description: "Display items with an image, heading, and subheading.", + type: "image", + card: { + heading: "Washington-state-mountain.jpg", + subHeading: "A photo of a beautiful mountain in the state of Washington", + thumbnail: "/images/integration-sample-image.png", + }, + }, + { + title: "Video Card", + description: "Display Shopify product listings", + type: "video", + card: { + heading: "Chugging through Sri Lanka's tea plantations", + subHeading: "13:10", + thumbnail: "/images/integration-sample-video.png", + }, + }, + { + title: "Details Card", + description: "Display items with multiple details", + type: "details", + card: { + heading: "Anfernee Simons", + subHeading: "A photo of a beautiful mountain in the state of Washington", + + // details: ["position", "stats.points"], + details: [ + { + key: "position", + value: 12, + }, + { + key: "stats.points", + value: 22, + }, + ], + }, + }, + { + title: "Simple Card", + description: "Display items with a heading and subheading", + type: "simple", + card: { + heading: "Lebron James", + }, + }, +]; + +export const SPECIAL_DISPLAY_TYPES: DisplayOptionCardProps[] = [ + { + title: "MUX Card", + description: "Display videos from MUX", + type: "mux", + card: { + heading: "HK01Bq7FrEQmIu3QpRiZZ98HQOOZjm6BYyg17eEunlyo", + subHeading: "13:10", + thumbnail: "/images/integration-sample-video.png", + }, + }, + { + title: "Youtube Card", + description: "Display videos from Youtube", + + type: "youtube", + card: { + heading: "Chugging through Sri Lanka's tea plantations", + subHeading: "13:10 • 92M views • 1 day ago", + thumbnail: "/images/integration-sample-video.png", + }, + }, + { + title: "Shopify Card", + description: "Display Shopify product listings", + type: "shopify", + card: { + heading: "Basic Chair", + subHeading: "Furniture", + detail: "$73.00", + thumbnail: "/images/integration-sample-image.png", + }, + }, + { + title: "Classy Card", + description: "Display campaigns from classy", + type: "classy", + card: { + heading: "Campaign Name", + subHeading: "Campaign Description", + }, + }, +]; + +const HEADING: ConfigProps = { + name: "heading", + label: "Heading", + type: "text", + isRequired: true, + placeholder: "Select", +}; + +const SUB_HEADING: ConfigProps = { + name: "subHeading", + label: "Sub Heading", + type: "text", + isRequired: true, + placeholder: "Select", +}; + +const IMAGE: ConfigProps = { + name: "thumbnail", + label: "Thumbnail", + type: "text", + isRequired: true, + placeholder: "Select", + description: "Image will only render if value selected is a URL", +}; + +export const DISPLAY_OPTIONS_CONFIG: Record = { + simple: [HEADING], + text: [HEADING, SUB_HEADING], + details: [ + HEADING, + { + name: "details", + label: "Details", + type: "option", + isRequired: true, + placeholder: "Select", + }, + ], + image: [HEADING, SUB_HEADING, IMAGE], + video: [HEADING, SUB_HEADING, IMAGE], + shopify: [ + HEADING, + SUB_HEADING, + IMAGE, + { + name: "detail", + label: "Detail", + type: "text", + isRequired: true, + placeholder: "Select", + }, + ], + youtube: [HEADING, SUB_HEADING, IMAGE], + mux: [HEADING, SUB_HEADING, IMAGE], + classy: [HEADING, SUB_HEADING], +}; + +export const LOADING_DATA = [...Array(10)].map((_, number) => ({ + id: number, + name: String(number), +})); diff --git a/src/shell/components/FieldTypeIntegration/useIntegrationField.ts b/src/shell/components/FieldTypeIntegration/useIntegrationField.ts new file mode 100644 index 0000000000..0192b5fbbe --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/useIntegrationField.ts @@ -0,0 +1,55 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { IntegrationRequestHeaders } from "../../services/types"; + +const useIntegrationField = () => { + const [apiData, setApiData] = useState(null); + const [status, setStatus] = useState< + "connecting" | "success" | "failed" | null + >(null); + + const hasFetchedRef = useRef(false); + + const fetchApiData = useCallback( + async (endpoint: string, headers?: IntegrationRequestHeaders) => { + if (!endpoint || hasFetchedRef.current) return; + + hasFetchedRef.current = true; + setStatus("connecting"); + + try { + const response = await fetch(endpoint, { + ...(headers ? { headers } : {}), + }); + + if (response.ok) { + const responseData = await response.json(); + setApiData(responseData); + setStatus("success"); + } else { + throw new Error( + `Failed to fetch data: ${response.status} ${response.statusText}` + ); + } + } catch (err) { + setApiData(null); + setStatus("failed"); + console.error("API fetch error:", err); + } + }, + [] + ); + + useEffect(() => { + return () => { + hasFetchedRef.current = false; + }; + }, []); + + return { + data: apiData, + status, + fetchApiData, + }; +}; + +export default useIntegrationField; diff --git a/src/shell/components/FieldTypeIntegration/utils.ts b/src/shell/components/FieldTypeIntegration/utils.ts new file mode 100644 index 0000000000..d0e44c4c21 --- /dev/null +++ b/src/shell/components/FieldTypeIntegration/utils.ts @@ -0,0 +1,17 @@ +export function getKeyValue(obj: T, path: K): any { + if (!obj || !path) return undefined; + + const keys = path?.split(".").flatMap((part) => { + const arrayMatch = part?.match(/([^\[]+)?\[(\d+)\]/); + if (arrayMatch) { + const [, prop, index] = arrayMatch; + return prop ? [prop, index] : [index]; + } + return [part]; + }); + + return keys?.reduce((acc: any, key) => { + if (acc === null || acc === undefined) return undefined; + return acc[key]; + }, obj); +} diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts index a8c0473715..8ef22c96fd 100644 --- a/src/shell/services/types.ts +++ b/src/shell/services/types.ts @@ -66,6 +66,37 @@ export interface File { export type ModelType = "pageset" | "templateset" | "dataset" | "block"; +export type IntegrationTypes = + | "simple" + | "text" + | "details" + | "image" + | "video" + | "shopify" + | "youtube" + | "mux" + | "classy"; + +export type IntegrationRequestHeaders = { + [key: string]: T; +}; + +export type IntegrationKeyPaths = { + rootPath?: string | null; + heading?: string | null; + subHeading?: string | null; + thumbnail?: string | null; + detail?: string | null; + details?: string[] | null; +}; + +export type IntegrationFieldConfig = { + endpoint: string; + headers: IntegrationRequestHeaders | null; + type: IntegrationTypes | null; + keyPaths: IntegrationKeyPaths | null; +}; + export interface ContentModel { ZUID: string; masterZUID: string; @@ -119,7 +150,7 @@ export interface Meta { createdByUserZUID: string; } export interface Data { - [key: string]: number | string | null | undefined; + [key: string]: number | string | null | undefined | unknown; } type UnorderedQuery = { @@ -217,7 +248,8 @@ export type ContentModelFieldValue = | boolean | string[] | FieldSettings - | FieldSettingsOptions[]; + | FieldSettingsOptions[] + | IntegrationFieldConfig; export type ContentModelFieldDataType = | "text" @@ -240,7 +272,8 @@ export type ContentModelFieldDataType = | "internal_link" | "yes_no" | "color" - | "sort"; + | "sort" + | "integration"; export interface ContentModelField { ZUID: string; @@ -261,6 +294,7 @@ export interface ContentModelField { createdAt: string; updatedAt: string; deletedAt: string; + integrationFieldConfig?: IntegrationFieldConfig; } export interface WebView { diff --git a/src/utility/validateUrl.ts b/src/utility/validateUrl.ts new file mode 100644 index 0000000000..10700efcd3 --- /dev/null +++ b/src/utility/validateUrl.ts @@ -0,0 +1,14 @@ +export const validateUrl = (url: string) => { + const validProtocols = ["http://", "https://"]; + + const hasValidProtocol = validProtocols.some((protocol) => + url.startsWith(protocol) + ); + if (!hasValidProtocol) return false; + try { + new URL(url); + return true; + } catch (_) { + return false; + } +};