├── .nvmrc ├── public ├── _redirects ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── .vscode └── settings.json ├── .babelrc ├── src ├── setupTests.js ├── extensions │ ├── Seo │ │ ├── index.js │ │ ├── __mocks__ │ │ │ ├── mockAppConfig.js │ │ │ └── mockFieldValue.js │ │ ├── Seo.scss │ │ ├── metaDataOptions.js │ │ ├── mockSdk.js │ │ └── README.md │ ├── Bynder │ │ ├── index.js │ │ ├── mockSdk.js │ │ ├── Bynder.scss │ │ └── Bynder.js │ ├── Address │ │ ├── index.js │ │ ├── Address.scss │ │ └── mockSdk.js │ ├── FormStack │ │ ├── index.js │ │ ├── __mocks__ │ │ │ ├── mockForm.js │ │ │ └── mockForms.js │ │ ├── mockSdk.js │ │ ├── FormStack.test.js │ │ └── FormStack.js │ ├── ColorPicker │ │ ├── index.js │ │ ├── mockSdk.js │ │ ├── ColorPicker.scss │ │ ├── README.md │ │ ├── ColorPicker.js │ │ └── ColorPicker.test.js │ ├── ContentDiff │ │ ├── index.js │ │ ├── helpers │ │ │ ├── index.js │ │ │ ├── lookups.test.js │ │ │ ├── createHtml.js │ │ │ ├── getters.js │ │ │ └── lookups.js │ │ ├── ContentDiff.js │ │ ├── constants.js │ │ ├── README.md │ │ └── ContentDiffSidebar.test.js │ ├── LocaleZooms │ │ ├── index.js │ │ ├── mockSdk.js │ │ └── README.md │ ├── PersonName │ │ ├── index.js │ │ ├── mockSdk.js │ │ ├── README.md │ │ └── PersonName.js │ ├── RecipeSteps │ │ ├── index.js │ │ ├── dialogs │ │ │ ├── index.js │ │ │ ├── BulkEditSteps.js │ │ │ └── StepDialog.js │ │ ├── helpers │ │ │ └── index.js │ │ ├── mockSdk.js │ │ ├── RecipeSteps.scss │ │ ├── RecipeSteps.js │ │ └── README.md │ ├── BynderImage │ │ ├── index.js │ │ ├── BynderImage.scss │ │ ├── README.md │ │ ├── mockSdk.js │ │ ├── BynderImage.test.js │ │ └── BynderImage.js │ ├── CoveoSearch │ │ ├── index.js │ │ ├── CoveoSearch.test.js │ │ ├── constants.js │ │ ├── CoveoSearchDialog.scss │ │ ├── CoveoSearchFieldDisplay.js │ │ ├── README.md │ │ ├── install.http │ │ ├── CoveoSearch.js │ │ ├── mockSdk.js │ │ ├── CoveoSearchResultList.js │ │ ├── CoveoReferenceSearch │ │ │ ├── CoveoReferenceSearchEntry.js │ │ │ └── CoveoReferenceSearchFieldDisplay.js │ │ ├── CoveoSearchHooks.js │ │ ├── CoveoSearchService.js │ │ └── CoveoSavedSearch │ │ │ └── CoveoSavedSearchFieldDisplay.js │ ├── FormBuilder │ │ ├── FormInfo │ │ │ ├── index.js │ │ │ ├── Providers │ │ │ │ ├── Redirect.js │ │ │ │ ├── Hubspot.js │ │ │ │ └── Custom.js │ │ │ ├── FormInfo.js │ │ │ └── modal2 │ │ ├── StepList │ │ │ ├── index.js │ │ │ ├── styles.js │ │ │ ├── utils.js │ │ │ └── ConfirmDeleteModal.js │ │ ├── index.js │ │ ├── SortableList │ │ │ └── index.js │ │ ├── SectionWrapper │ │ │ ├── index.js │ │ │ └── SectionWrapper.js │ │ ├── ConfirmDeleteDialog │ │ │ ├── index.js │ │ │ └── ConfirmDeleteDialog.js │ │ ├── images │ │ │ ├── overview.png │ │ │ ├── ModalEditor.png │ │ │ ├── ModalField.png │ │ │ └── ModalField2.png │ │ ├── hooks │ │ │ ├── index.js │ │ │ ├── utils.js │ │ │ ├── test.helpers.js │ │ │ ├── useFormConfig.js │ │ │ ├── useFieldConfig.js │ │ │ ├── useFieldConfig.test.js │ │ │ ├── useFormSteps.test.js │ │ │ ├── useFormSteps.js │ │ │ ├── useProviderConfig.js │ │ │ └── useProviderConfig.test.js │ │ ├── FieldModal │ │ │ ├── styles.js │ │ │ ├── SchemaEditor │ │ │ │ ├── prop-types.js │ │ │ │ └── index.js │ │ │ ├── AdditionalFields │ │ │ │ ├── index.js │ │ │ │ ├── Hidden.js │ │ │ │ └── Toggleable.js │ │ │ ├── index.js │ │ │ ├── FieldTypeSelector.js │ │ │ └── FieldEditor.js │ │ ├── styles.js │ │ ├── mockSdk.js │ │ ├── StepModal │ │ │ ├── index.js │ │ │ └── StepEditor.js │ │ ├── utils.js │ │ ├── FormBuilder.scss │ │ └── EditorModal │ │ │ └── styles.js │ ├── PhoneNumber │ │ ├── index.js │ │ ├── mockSdk.js │ │ ├── README.md │ │ ├── PhoneNumber.js │ │ └── PhoneNumber.test.js │ ├── OperatingHours │ │ ├── index.js │ │ ├── DaysOfWeekTable │ │ │ ├── index.js │ │ │ └── DaysOfWeekTable.js │ │ ├── OverrideDaysTable │ │ │ ├── index.js │ │ │ └── OverrideDaysTableRow.js │ │ ├── FriendlyLabelsTable │ │ │ ├── index.js │ │ │ └── EditForm.js │ │ ├── mockSdk.js │ │ └── OperatingHours.scss │ ├── RecipeIngredients │ │ ├── index.js │ │ ├── dialogs │ │ │ └── index.js │ │ ├── helpers │ │ │ ├── index.js │ │ │ └── selectValues.js │ │ ├── RecipeIngredients.js │ │ ├── mockSdk.js │ │ ├── README.md │ │ └── RecipeIngredients.scss │ ├── LocalizationLookup │ │ ├── index.js │ │ ├── mockSdk.js │ │ └── README.md │ ├── NotLoaded.js │ └── ExtensionsList.js ├── history.js ├── shared │ ├── components │ │ ├── TimeRange │ │ │ ├── index.js │ │ │ └── TimeRange.js │ │ ├── DatePicker │ │ │ ├── index.js │ │ │ └── DatePicker.js │ │ ├── StateDropdown │ │ │ ├── index.js │ │ │ └── StateDropdown.js │ │ ├── TimezoneDropdown │ │ │ ├── index.js │ │ │ └── TimezoneDropdown.js │ │ └── SingleAssetWithButton │ │ │ └── index.js │ ├── helpers │ │ ├── openDialog.js │ │ ├── index.js │ │ ├── utility.js │ │ └── utility.test.js │ └── modules │ │ └── getWorkflowState.js ├── context.js ├── sdkPropTypes.js ├── __mocks__ │ ├── mockLocations.js │ ├── mockContentfulSdk.js │ └── mockContentfulAsset.js └── index.js ├── new-app.sh ├── .sonarcloud.properties ├── .gitignore ├── .prettierrc ├── .eslintrc.js └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.16.3 -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true 3 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["styled-components"] 3 | } 4 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | HTMLCanvasElement.prototype.getContext = () => {}; 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/extensions/Seo/index.js: -------------------------------------------------------------------------------- 1 | import Seo from './Seo'; 2 | 3 | export default Seo; 4 | -------------------------------------------------------------------------------- /src/extensions/Bynder/index.js: -------------------------------------------------------------------------------- 1 | import Bynder from './Bynder'; 2 | 3 | export default Bynder; 4 | -------------------------------------------------------------------------------- /src/extensions/Address/index.js: -------------------------------------------------------------------------------- 1 | import Address from './Address'; 2 | 3 | export default Address; 4 | -------------------------------------------------------------------------------- /src/extensions/Address/Address.scss: -------------------------------------------------------------------------------- 1 | .Address { 2 | &__field { 3 | margin-bottom: 1rem; 4 | } 5 | } -------------------------------------------------------------------------------- /src/extensions/FormStack/index.js: -------------------------------------------------------------------------------- 1 | import FormStack from './FormStack'; 2 | 3 | export default FormStack; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-rev-llc/contentful-ui-extensions/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-rev-llc/contentful-ui-extensions/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-rev-llc/contentful-ui-extensions/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/extensions/ColorPicker/index.js: -------------------------------------------------------------------------------- 1 | import ColorPicker from './ColorPicker'; 2 | 3 | export default ColorPicker; -------------------------------------------------------------------------------- /src/extensions/ContentDiff/index.js: -------------------------------------------------------------------------------- 1 | import ContentDiff from './ContentDiff'; 2 | 3 | export default ContentDiff; -------------------------------------------------------------------------------- /src/extensions/LocaleZooms/index.js: -------------------------------------------------------------------------------- 1 | import LocaleZooms from './LocaleZooms'; 2 | 3 | export default LocaleZooms; -------------------------------------------------------------------------------- /src/extensions/PersonName/index.js: -------------------------------------------------------------------------------- 1 | import PersonName from './PersonName'; 2 | 3 | export default PersonName; 4 | -------------------------------------------------------------------------------- /src/extensions/RecipeSteps/index.js: -------------------------------------------------------------------------------- 1 | import RecipeSteps from './RecipeSteps'; 2 | 3 | export default RecipeSteps; -------------------------------------------------------------------------------- /src/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | export default createBrowserHistory(); -------------------------------------------------------------------------------- /src/extensions/BynderImage/index.js: -------------------------------------------------------------------------------- 1 | import BynderImage from "./BynderImage"; 2 | 3 | export default BynderImage; 4 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/index.js: -------------------------------------------------------------------------------- 1 | import CoveoSearch from "./CoveoSearch"; 2 | 3 | export default CoveoSearch; 4 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FormInfo/index.js: -------------------------------------------------------------------------------- 1 | import FormInfo from './FormInfo'; 2 | 3 | export default FormInfo; 4 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/StepList/index.js: -------------------------------------------------------------------------------- 1 | import StepList from './StepList'; 2 | 3 | export default StepList; 4 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/index.js: -------------------------------------------------------------------------------- 1 | import FormBuilder from "./FormBuilder"; 2 | 3 | export default FormBuilder; 4 | -------------------------------------------------------------------------------- /src/extensions/PhoneNumber/index.js: -------------------------------------------------------------------------------- 1 | import PhoneNumber from './PhoneNumber'; 2 | 3 | export default PhoneNumber; 4 | -------------------------------------------------------------------------------- /src/shared/components/TimeRange/index.js: -------------------------------------------------------------------------------- 1 | import TimeRange from './TimeRange'; 2 | 3 | export default TimeRange; 4 | -------------------------------------------------------------------------------- /src/shared/components/DatePicker/index.js: -------------------------------------------------------------------------------- 1 | import DatePicker from './DatePicker'; 2 | 3 | export default DatePicker; 4 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/SortableList/index.js: -------------------------------------------------------------------------------- 1 | import SortableList from "./SortableList"; 2 | 3 | export default SortableList; 4 | -------------------------------------------------------------------------------- /src/extensions/OperatingHours/index.js: -------------------------------------------------------------------------------- 1 | import OperatingHours from './OperatingHours'; 2 | 3 | export default OperatingHours; 4 | -------------------------------------------------------------------------------- /src/shared/components/StateDropdown/index.js: -------------------------------------------------------------------------------- 1 | import StateDropdown from './StateDropdown'; 2 | 3 | export default StateDropdown; 4 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoSearch.test.js: -------------------------------------------------------------------------------- 1 | describe("", () => { 2 | test.todo("test this extension"); 3 | }); 4 | -------------------------------------------------------------------------------- /src/extensions/RecipeIngredients/index.js: -------------------------------------------------------------------------------- 1 | import RecipeIngredients from './RecipeIngredients'; 2 | 3 | export default RecipeIngredients; -------------------------------------------------------------------------------- /src/extensions/FormBuilder/SectionWrapper/index.js: -------------------------------------------------------------------------------- 1 | import SectionWrapper from "./SectionWrapper"; 2 | 3 | export default SectionWrapper; 4 | -------------------------------------------------------------------------------- /src/extensions/LocalizationLookup/index.js: -------------------------------------------------------------------------------- 1 | import LocalizationLookup from './LocalizationLookup'; 2 | 3 | export default LocalizationLookup; -------------------------------------------------------------------------------- /src/extensions/OperatingHours/DaysOfWeekTable/index.js: -------------------------------------------------------------------------------- 1 | import DaysOfWeekTable from './DaysOfWeekTable'; 2 | 3 | export default DaysOfWeekTable; 4 | -------------------------------------------------------------------------------- /src/shared/components/TimezoneDropdown/index.js: -------------------------------------------------------------------------------- 1 | import TimezoneDropdown from './TimezoneDropdown'; 2 | 3 | export default TimezoneDropdown; 4 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/constants.js: -------------------------------------------------------------------------------- 1 | export const TYPE_SAVED_SEARCH = "TYPE_SAVED_SEARCH"; 2 | export const TYPE_REF_SEARCH = "TYPE_REF_SEARCH"; 3 | -------------------------------------------------------------------------------- /src/extensions/OperatingHours/OverrideDaysTable/index.js: -------------------------------------------------------------------------------- 1 | import OverrideDaysTable from './OverrideDaysTable'; 2 | 3 | export default OverrideDaysTable; 4 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/ConfirmDeleteDialog/index.js: -------------------------------------------------------------------------------- 1 | import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; 2 | 3 | export default ConfirmDeleteDialog; 4 | -------------------------------------------------------------------------------- /src/shared/components/SingleAssetWithButton/index.js: -------------------------------------------------------------------------------- 1 | import SingleAssetWithButton from './SingleAssetWithButton'; 2 | 3 | export default SingleAssetWithButton; -------------------------------------------------------------------------------- /src/extensions/FormBuilder/images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-rev-llc/contentful-ui-extensions/HEAD/src/extensions/FormBuilder/images/overview.png -------------------------------------------------------------------------------- /src/extensions/OperatingHours/FriendlyLabelsTable/index.js: -------------------------------------------------------------------------------- 1 | import FriendlyLabelsTable from './FriendlyLabelsTable'; 2 | 3 | export default FriendlyLabelsTable; 4 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/images/ModalEditor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-rev-llc/contentful-ui-extensions/HEAD/src/extensions/FormBuilder/images/ModalEditor.png -------------------------------------------------------------------------------- /src/extensions/FormBuilder/images/ModalField.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-rev-llc/contentful-ui-extensions/HEAD/src/extensions/FormBuilder/images/ModalField.png -------------------------------------------------------------------------------- /src/extensions/FormBuilder/images/ModalField2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last-rev-llc/contentful-ui-extensions/HEAD/src/extensions/FormBuilder/images/ModalField2.png -------------------------------------------------------------------------------- /src/extensions/ContentDiff/helpers/index.js: -------------------------------------------------------------------------------- 1 | export * from './simpleObjects'; 2 | export * from './lookups'; 3 | export * from './createHtml'; 4 | export * from './getters'; 5 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoSearchDialog.scss: -------------------------------------------------------------------------------- 1 | .coveo-result-list-container, 2 | .CoveoResult { 3 | display: none; 4 | } 5 | 6 | .CoveoResultList .result { 7 | display: block; 8 | } 9 | -------------------------------------------------------------------------------- /src/extensions/BynderImage/BynderImage.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div { 4 | margin: 0; 5 | padding: 0; 6 | border: 0; 7 | font: inherit; 8 | vertical-align: baseline; 9 | } 10 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | export const SDKContext = React.createContext({}); 4 | 5 | export const useSDK = () => useContext(SDKContext); 6 | 7 | export default { SDKContext, useSDK }; 8 | -------------------------------------------------------------------------------- /src/extensions/NotLoaded.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function NotLoaded() { 4 | return ( 5 |
6 | There was an error loading the UI Extension. Please try again. 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/extensions/FormStack/__mocks__/mockForm.js: -------------------------------------------------------------------------------- 1 | import { random } from 'faker'; 2 | 3 | export default (formId) => ({ 4 | id: formId || random.number(10000), 5 | name: random.word(), 6 | url: `http://www.${random.alphaNumeric(10)}.com` 7 | }); 8 | -------------------------------------------------------------------------------- /src/extensions/FormStack/mockSdk.js: -------------------------------------------------------------------------------- 1 | const mockSdk = { 2 | field: { 3 | getValue: () => { 4 | return null; 5 | }, 6 | setValue: () => { 7 | return null; 8 | } 9 | } 10 | }; 11 | 12 | export default mockSdk; 13 | -------------------------------------------------------------------------------- /src/extensions/RecipeSteps/dialogs/index.js: -------------------------------------------------------------------------------- 1 | import openDialog from '../../../shared/helpers/openDialog'; 2 | import StepDialog from './StepDialog'; 3 | import BulkEditSteps from './BulkEditSteps'; 4 | 5 | export { openDialog, StepDialog, BulkEditSteps }; 6 | -------------------------------------------------------------------------------- /src/extensions/FormStack/__mocks__/mockForms.js: -------------------------------------------------------------------------------- 1 | import mockForm from './mockForm'; 2 | 3 | export default (numberOfForms = 3) => { 4 | const forms = []; 5 | for (let i = 0; i < numberOfForms; i++) { 6 | forms.push(mockForm(i)); 7 | } 8 | return forms; 9 | }; 10 | -------------------------------------------------------------------------------- /src/extensions/Bynder/mockSdk.js: -------------------------------------------------------------------------------- 1 | const mockSdk = { 2 | dialogs: { 3 | openExtension: () => {} 4 | }, 5 | field: { 6 | getValue: () => {}, 7 | setValue: val => val 8 | }, 9 | location: { 10 | is: () => false 11 | } 12 | }; 13 | 14 | export default mockSdk; 15 | -------------------------------------------------------------------------------- /src/extensions/RecipeIngredients/dialogs/index.js: -------------------------------------------------------------------------------- 1 | import openDialog from '../../../shared/helpers/openDialog'; 2 | import IngredientDialog from './IngredientDialog'; 3 | import BulkEditIngredients from './BulkEditIngredients'; 4 | 5 | export { openDialog, IngredientDialog, BulkEditIngredients }; 6 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/index.js: -------------------------------------------------------------------------------- 1 | export { default as useFieldConfig } from './useFieldConfig'; 2 | export { default as useFormConfig } from './useFormConfig'; 3 | export { default as useFormSteps } from './useFormSteps'; 4 | export { default as useProviderConfig } from './useProviderConfig'; 5 | -------------------------------------------------------------------------------- /src/shared/helpers/openDialog.js: -------------------------------------------------------------------------------- 1 | const openDialog = (sdk, title, parameters, width = 'large', allowHeightOverflow = true) => { 2 | return sdk.dialogs.openExtension({ 3 | width, 4 | title, 5 | allowHeightOverflow, 6 | parameters 7 | }); 8 | }; 9 | 10 | export default openDialog; 11 | -------------------------------------------------------------------------------- /src/extensions/Bynder/Bynder.scss: -------------------------------------------------------------------------------- 1 | .Bynder { 2 | &__card { 3 | margin: 10px; 4 | position: relative; 5 | width: 150px; 6 | height: 100px; 7 | 8 | > img { 9 | display: block; 10 | max-width: 150px; 11 | max-height: 100px; 12 | margin: auto; 13 | user-select: none; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /new-app.sh: -------------------------------------------------------------------------------- 1 | curl -X POST \ 2 | -H'Content-Type: application/json' \ 3 | -H'Authorization: Bearer ' \ 4 | -d'{"name": "LOCAL Last Rev: SEO", "src": "http://localhost:3000/seo", "locations": ["app", "entry-field"], "fieldTypes": [{"type": "Object"}]}' \ 5 | https://api.contentful.com/organizations/3zfj70Xm2lKIeJmRkM5HnS/app_definitions -------------------------------------------------------------------------------- /src/extensions/PhoneNumber/mockSdk.js: -------------------------------------------------------------------------------- 1 | const sdk = { 2 | field: { 3 | getValue: () => { 4 | return { 5 | label: "Test Label", 6 | phoneNumber: "5555555555", 7 | extension: "1234", 8 | }; 9 | }, 10 | setValue: (value) => { 11 | return value; 12 | }, 13 | }, 14 | }; 15 | 16 | export default sdk; -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FieldModal/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Row = styled.div` 4 | display: flex; 5 | align-items: center; 6 | flex-direction: row; 7 | 8 | > * { 9 | margin-right: 4px; 10 | } 11 | 12 | label { 13 | margin-bottom: 0; 14 | } 15 | `; 16 | 17 | export default { Row }; 18 | -------------------------------------------------------------------------------- /src/extensions/RecipeIngredients/helpers/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | getTextInput, 3 | getOptions, 4 | getSelect, 5 | withLabel, 6 | getIngredientRows, 7 | getIngredientsTable 8 | } from './formControl'; 9 | 10 | export { 11 | getTextInput, 12 | getOptions, 13 | getSelect, 14 | withLabel, 15 | getIngredientRows, 16 | getIngredientsTable 17 | }; -------------------------------------------------------------------------------- /src/sdkPropTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | const sdkProps = PropTypes.shape({ 4 | field: PropTypes.shape({ 5 | getValue: PropTypes.func.isRequired, 6 | setValue: PropTypes.func.isRequired, 7 | validations: PropTypes.arrayOf(PropTypes.shape({ 8 | in: PropTypes.array, 9 | })) 10 | }) 11 | }); 12 | 13 | export default sdkProps; -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | # Path to sources 2 | #sonar.sources=. 3 | sonar.exclusions=node_modules/**/*, **/*.test.* 4 | #sonar.inclusions= 5 | 6 | # Path to tests 7 | #sonar.tests= 8 | #sonar.test.exclusions= 9 | #sonar.test.inclusions= 10 | 11 | # Source encoding 12 | #sonar.sourceEncoding=UTF-8 13 | 14 | # Exclusions for copy-paste detection 15 | #sonar.cpd.exclusions= 16 | -------------------------------------------------------------------------------- /src/extensions/RecipeSteps/helpers/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | getTextInput, 3 | getTextInputWithLabel, 4 | getTextArea, 5 | getTextAreaWithLabel, 6 | getStepRows, 7 | getStepsTable 8 | } from './formControl'; 9 | 10 | export { 11 | getTextInput, 12 | getTextInputWithLabel, 13 | getTextArea, 14 | getTextAreaWithLabel, 15 | getStepRows, 16 | getStepsTable 17 | }; -------------------------------------------------------------------------------- /src/__mocks__/mockLocations.js: -------------------------------------------------------------------------------- 1 | const locations = { 2 | LOCATION_ENTRY_FIELD: "entry-field", 3 | LOCATION_ENTRY_FIELD_SIDEBAR: "entry-field-sidebar", 4 | LOCATION_ENTRY_SIDEBAR: "entry-sidebar", 5 | LOCATION_DIALOG: "dialog", 6 | LOCATION_ENTRY_EDITOR: "entry-editor", 7 | LOCATION_PAGE: "page", 8 | LOCATION_APP_CONFIG: "app-config", 9 | }; 10 | 11 | export default locations; -------------------------------------------------------------------------------- /src/extensions/FormBuilder/StepList/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { ValidationMessage } from '@contentful/forma-36-react-components'; 3 | 4 | export const WarningStyle = styled(ValidationMessage)` 5 | svg { 6 | fill: #ff8c00; 7 | } 8 | 9 | p { 10 | color: #ff8c00; 11 | } 12 | `; 13 | 14 | export const ErrorStyle = styled(ValidationMessage)``; 15 | -------------------------------------------------------------------------------- /src/extensions/ColorPicker/mockSdk.js: -------------------------------------------------------------------------------- 1 | const mockSdk = { 2 | field: { 3 | validations: [ 4 | { 5 | in: [ 6 | '#BBBBBB', 7 | '#000000', 8 | '#333333' 9 | ] 10 | } 11 | ], 12 | getValue: () => { 13 | return '#BBBBBB'; 14 | }, 15 | setValue: () => { 16 | return null; 17 | } 18 | }, 19 | }; 20 | 21 | export default mockSdk; -------------------------------------------------------------------------------- /src/extensions/LocalizationLookup/mockSdk.js: -------------------------------------------------------------------------------- 1 | const mockSdk = { 2 | field: { 3 | getValue: () => { 4 | return { 5 | "key1": "value1", 6 | "key2": "value2", 7 | "key3": "value3", 8 | "key4": "value4", 9 | "key5": "value5", 10 | }; 11 | }, 12 | setValue: value => { 13 | return value; 14 | } 15 | }, 16 | }; 17 | 18 | export default mockSdk; -------------------------------------------------------------------------------- /src/extensions/PersonName/mockSdk.js: -------------------------------------------------------------------------------- 1 | const sdk = { 2 | field: { 3 | getValue: () => ({ 4 | salutation: "Test salutation", 5 | firstName: "Test firstName", 6 | middleName: "Test middleName", 7 | lastName: "Test lastName", 8 | suffix: "Test suffix", 9 | nickname: "Test nickname", 10 | }), 11 | setValue: val => val, 12 | }, 13 | }; 14 | 15 | export default sdk; 16 | -------------------------------------------------------------------------------- /src/extensions/Address/mockSdk.js: -------------------------------------------------------------------------------- 1 | const sdk = { 2 | field: { 3 | getValue: () => ({ 4 | displayText: "", 5 | displaySummary: "", 6 | streetAddress: "", 7 | streetAddress2: "", 8 | city: "", 9 | state: "", 10 | postalCode: "", 11 | latitude: "", 12 | longitude: "", 13 | googlePlacesId: "" 14 | }), 15 | setValue: val => val, 16 | }, 17 | }; 18 | 19 | export default sdk; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | .env 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # Local Netlify folder 28 | .netlify -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "consistent", 8 | "jsxSingleQuote": false, 9 | "trailingComma": "none", 10 | "bracketSpacing": true, 11 | "jsxBracketSameLine": true, 12 | "arrowParens": "always", 13 | "endOfLine": "lf", 14 | "overrides": [ 15 | { 16 | "files": ".prettierrc", 17 | "options": { "parser": "json" } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/helpers/index.js: -------------------------------------------------------------------------------- 1 | import { getButton, getIconButton, getSelect, getTextField, getOptions, withLabel } from './formControl'; 2 | import { updateJson, getError, hasDuplicate, getInfo } from './utility'; 3 | 4 | import openDialog from './openDialog'; 5 | 6 | export { 7 | getButton, 8 | getIconButton, 9 | getSelect, 10 | getTextField, 11 | getOptions, 12 | withLabel, 13 | updateJson, 14 | getError, 15 | hasDuplicate, 16 | getInfo, 17 | openDialog 18 | }; 19 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ModalStyle = styled.div` 4 | > h1 { 5 | padding: 24px; 6 | 7 | border-bottom: 1px solid lightgrey; 8 | } 9 | 10 | position: relative; 11 | padding-bottom: 80px; 12 | footer { 13 | position: fixed; 14 | bottom: 0; 15 | width: 100%; 16 | padding: 12px; 17 | background: white; 18 | border-top: 1px solid lightgrey; 19 | } 20 | `; 21 | 22 | export default { ModalStyle }; 23 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FieldModal/SchemaEditor/prop-types.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | const schemaProp = PropTypes.shape({ 4 | type: PropTypes.string, 5 | min: PropTypes.number, 6 | max: PropTypes.number, 7 | pattern: PropTypes.string, 8 | messages: PropTypes.object // { stringMin: "Some error" } 9 | }); 10 | 11 | export const schemaPropType = PropTypes.oneOfType([ 12 | // Optional multi-type schema (TODO) 13 | PropTypes.arrayOf(schemaProp), 14 | schemaProp 15 | ]); 16 | 17 | export default { schemaPropType }; 18 | -------------------------------------------------------------------------------- /src/extensions/LocaleZooms/mockSdk.js: -------------------------------------------------------------------------------- 1 | const mockSdk = { 2 | field: { 3 | getValue: () => { 4 | return { 5 | "en-US": "9876543210", 6 | "de-DE": "1234567890" 7 | }; 8 | }, 9 | setValue: value => { 10 | return value; 11 | } 12 | }, 13 | locales: { 14 | names: { 15 | 'en-US': 'English (United States)', 16 | 'es-MX': 'Spanish (Mexico)', 17 | 'fr': 'French', 18 | 'de-DE': 'German (Germany)' 19 | } 20 | }, 21 | dialogs: { 22 | openConfirm: async () => true 23 | }, 24 | }; 25 | 26 | export default mockSdk; -------------------------------------------------------------------------------- /src/extensions/FormBuilder/mockSdk.js: -------------------------------------------------------------------------------- 1 | const mockSdk = { 2 | field: { 3 | getValue: () => { 4 | return { 5 | "en-US": "9876543210", 6 | "de-DE": "1234567890", 7 | }; 8 | }, 9 | setValue: (value) => { 10 | return value; 11 | }, 12 | }, 13 | locales: { 14 | names: { 15 | "en-US": "English (United States)", 16 | "es-MX": "Spanish (Mexico)", 17 | fr: "French", 18 | "de-DE": "German (Germany)", 19 | }, 20 | }, 21 | dialogs: { 22 | openConfirm: async () => true, 23 | }, 24 | }; 25 | 26 | export default mockSdk; 27 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/extensions/Seo/__mocks__/mockAppConfig.js: -------------------------------------------------------------------------------- 1 | const mockAppConfig = { 2 | siteName: 'Last Rev', 3 | pageTitleDelimiter: '|', 4 | editorInterface: { 5 | seoApp: { 6 | controls: [ 7 | { 8 | fieldId: 'seo', 9 | settings: { 10 | defaultNoIndex: false, 11 | defaultPageTitleField: 'title', 12 | defaultDescriptionField: 'description', 13 | defaultCanonicalField: 'canonical', 14 | defaultSocialImageField: 'mainImage' 15 | } 16 | } 17 | ] 18 | } 19 | } 20 | }; 21 | 22 | export default mockAppConfig; 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['react-app', 'airbnb', 'plugin:jsx-a11y/recommended', 'prettier', 'prettier/react'], 3 | plugins: ['jsx-a11y', 'prettier'], 4 | rules: { 5 | 'react/jsx-filename-extension': 0, 6 | 'function-paren-newline': 0, 7 | 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 8 | 'no-underscore-dangle': 0, 9 | 'react/jsx-one-expression-per-line': 0, 10 | 'import/no-cycle': 0, 11 | 'react/jsx-indent-props': [2, 2], 12 | 'array-callback-return': 0, 13 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 14 | 'react/jsx-indent': ['error', 2], 15 | 'semi': ['error', 'always'], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/SectionWrapper/SectionWrapper.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Card, Heading } from '@contentful/forma-36-react-components'; 4 | 5 | const SectionWrapperPropTypes = { 6 | children: PropTypes.node.isRequired, 7 | title: PropTypes.oneOfType([ 8 | // 9 | PropTypes.func, 10 | PropTypes.node, 11 | PropTypes.string 12 | ]).isRequired 13 | }; 14 | 15 | const SectionWrapper = ({ title, children }) => { 16 | return ( 17 | 18 | {title} 19 | {children} 20 | 21 | ); 22 | }; 23 | 24 | SectionWrapper.propTypes = SectionWrapperPropTypes; 25 | 26 | SectionWrapper.defaultProps = {}; 27 | 28 | export default SectionWrapper; 29 | -------------------------------------------------------------------------------- /src/extensions/RecipeSteps/mockSdk.js: -------------------------------------------------------------------------------- 1 | const mockSdk = { 2 | field: { 3 | getValue: () => { 4 | return [ 5 | { 6 | 'title': 'Step 1', 7 | 'body': 'Body 1', 8 | }, { 9 | 'title': 'Step 2', 10 | 'body': 'Body 2', 11 | } 12 | ]; 13 | }, 14 | setValue: value => { 15 | return value; 16 | } 17 | }, 18 | location: { 19 | is: () => true, 20 | }, 21 | parameters: { 22 | invocation: { 23 | step: { 24 | step: "1", 25 | title: 'Step 1', 26 | body: 'Body 1' 27 | } 28 | } 29 | }, 30 | close: (value) => value, 31 | }; 32 | 33 | export const sdkList = { 34 | ...mockSdk, 35 | location: { 36 | is: () => false, 37 | } 38 | }; 39 | 40 | export default mockSdk; 41 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoSearchFieldDisplay.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import { get } from "lodash"; 3 | import React from "react"; 4 | import CoveoReferenceSearchFieldDisplay from "./CoveoReferenceSearch/CoveoReferenceSearchFieldDisplay"; 5 | import CoveoSavedSearchFieldDisplay from "./CoveoSavedSearch/CoveoSavedSearchFieldDisplay"; 6 | 7 | function CoveoSearchFieldDisplay({ sdk }) { 8 | const { 9 | field: { type, items } 10 | } = sdk; 11 | 12 | if (type === "Object") { 13 | return ; 14 | } 15 | 16 | if (type === "Array" && get(items, "linkType") === "Entry") { 17 | return ; 18 | } 19 | 20 | return
This field type is not supported.
; 21 | } 22 | 23 | export default CoveoSearchFieldDisplay; 24 | -------------------------------------------------------------------------------- /src/extensions/BynderImage/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: Bynder Image 2 | 3 | The Last Rev Bynder Image extension is a field extension that copies information contained in Bynder Image JSON to Contentful fields. 4 | 5 | 6 | ## Setup Instructions 7 | 8 | 1. Create a content model with the following ids and fields: 9 | * bynderId : Symbol 10 | * imageName : Symbol 11 | * internalTitle : Symbol 12 | * altTextOverride : Symbol 13 | * bynderData : JSON 14 | 15 | 2. Install the extension in Contentful 16 | 3. Choose the bynderId field and set it's appearance to use this extension 17 | 4. That's it. Whenever the bynder Data is changed the other fields will update with the JSON information. 18 | 19 | ## Reporting Issues 20 | 21 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! 22 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: Coveo Search 2 | 3 | use a pre-configured search to either find entries to add to a reference field, or to generate a search to be used on the client side. 4 | 5 | ## Setup Instructions 6 | 7 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 8 | 2. Install the extension using the Contentful content management API. See example at [./install.http](./install.http). 9 | 10 | ## Field configuration 11 | 12 | Whenever this extension is added to a field, the Coveo search page name used for the search needs to be entered. 13 | 14 | ## Reporting Issues 15 | 16 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! 17 | -------------------------------------------------------------------------------- /src/extensions/OperatingHours/mockSdk.js: -------------------------------------------------------------------------------- 1 | const sdk = { 2 | field: { 3 | getValue: () => ({ 4 | daysOfWeek: [ 5 | { dayOfWeek: 'Monday', isClosed: false, timezone: 'America/Chicago' }, 6 | { dayOfWeek: 'Tuesday', isClosed: false, timezone: 'America/Chicago' }, 7 | { dayOfWeek: 'Wednesday', isClosed: false, timezone: 'America/Chicago' }, 8 | { dayOfWeek: 'Thursday', isClosed: false, timezone: 'America/Chicago' }, 9 | { dayOfWeek: 'Friday', isClosed: false, timezone: 'America/Chicago' }, 10 | { dayOfWeek: 'Saturday', isClosed: false, timezone: 'America/Chicago' }, 11 | { dayOfWeek: 'Sunday', isClosed: false, timezone: 'America/Chicago' }, 12 | ], 13 | overrideDays: [], 14 | friendlyLabels: [], 15 | }), 16 | setValue: val => val, 17 | }, 18 | }; 19 | 20 | export default sdk; 21 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/utils.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | export function ensureIds(steps) { 4 | return steps.map((step) => ({ 5 | id: uuidv4(), 6 | ...step, 7 | fields: step.fields.map((field) => ({ id: uuidv4(), ...field })) 8 | })); 9 | } 10 | 11 | export function buildField({ name = 'no_name', type = 'hidden', value = false } = {}) { 12 | return { 13 | id: uuidv4(), 14 | type, 15 | name, 16 | value 17 | }; 18 | } 19 | 20 | export function buildStep(title = 'Step title') { 21 | return { 22 | title, 23 | id: uuidv4(), 24 | fields: [ 25 | // 26 | buildField({ name: 'first_field' }) 27 | ] 28 | }; 29 | } 30 | 31 | export const URL_TYPES = ['hubspot', 'redirect']; 32 | 33 | export default { 34 | URL_TYPES, 35 | buildStep, 36 | buildField, 37 | ensureIds 38 | }; 39 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { init, locations } from 'contentful-ui-extensions-sdk'; 4 | import * as Sentry from '@sentry/react'; 5 | import App from './extensions/App'; 6 | import mockLocations from './__mocks__/mockLocations'; 7 | 8 | (()=> { 9 | Sentry.init({dsn: process.env.REACT_APP_SENTRY_URL}); 10 | if(window.self !== window.top) { 11 | // Being loaded by an iFrame (Contentful) 12 | init(sdk => { 13 | ReactDOM.render( 14 | , 16 | document.querySelector('#root') 17 | ); 18 | sdk.window.startAutoResizer(); 19 | }); 20 | } else { 21 | // Loaded locally 22 | ReactDOM.render( 23 | , 24 | document.querySelector('#root') 25 | ); 26 | } 27 | })(); 28 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/StepList/utils.js: -------------------------------------------------------------------------------- 1 | import { pickBy } from 'lodash/fp'; 2 | 3 | export function extractValue(maybeEvent) { 4 | // Dealing with event 5 | if (maybeEvent instanceof Object && maybeEvent.currentTarget) { 6 | return maybeEvent.currentTarget.value; 7 | } 8 | 9 | return maybeEvent; 10 | } 11 | 12 | export function hasValue(value) { 13 | if (value instanceof Object) { 14 | // Dealing with an event 15 | if (value.currentTarget) { 16 | return hasValue(extractValue(value)); 17 | } 18 | 19 | return Object.keys(value).length > 0; 20 | } 21 | 22 | return Boolean(value); 23 | } 24 | 25 | const cleanup = pickBy((item) => [null, undefined].includes(item) === false); 26 | 27 | export function denormalizeValues(field) { 28 | return cleanup(field); 29 | } 30 | 31 | export function normalizeValues(field) { 32 | return cleanup(field); 33 | } 34 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/test.helpers.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { clone, set } from 'lodash'; 3 | import { renderHook, act } from '@testing-library/react-hooks'; 4 | import useFormSteps from './useFormSteps'; 5 | 6 | /* eslint-disable import/prefer-default-export */ 7 | export function getStepsStateShim(defaultSteps = []) { 8 | // Now render our formSteps functionality 9 | return renderHook(() => { 10 | const [internalState, setInternalState] = useState({ steps: defaultSteps }); 11 | 12 | // the functionality passed to useFormSteps should set a deep key in the state 13 | // we can use lodash set for this purpose 14 | const setStateWrapper = (key, value) => act(() => setInternalState(set(clone(internalState), key, value))); 15 | 16 | return { formSteps: useFormSteps(setStateWrapper, internalState), state: internalState }; 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/StepModal/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { curry, omit } from 'lodash/fp'; 3 | 4 | import { Heading } from '@contentful/forma-36-react-components'; 5 | import { useSDK } from '../../../context'; 6 | 7 | import { ModalStyle } from '../styles'; 8 | import StepEditor from './StepEditor'; 9 | 10 | function StepModal() { 11 | const sdk = useSDK(); 12 | const { invocation } = sdk.parameters; 13 | const [step, setStep] = useState(omit(['modal'], invocation.step || {})); 14 | 15 | const updateStep = curry((key, value) => { 16 | setStep((prev) => ({ 17 | ...prev, 18 | [key]: value 19 | })); 20 | }); 21 | 22 | return ( 23 | 24 | Edit Step 25 | 26 | 27 | ); 28 | } 29 | 30 | export default StepModal; 31 | -------------------------------------------------------------------------------- /src/extensions/ContentDiff/ContentDiff.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { locations } from 'contentful-ui-extensions-sdk'; 4 | import DiffSidebar from './ContentDiffSidebar'; 5 | import DiffDialog from './ContentDiffDialog'; 6 | 7 | const ContentDiff = ({ sdk }) => { 8 | return !sdk.location.is(locations.LOCATION_DIALOG) 9 | ? ( 10 | <> 11 | 12 | 13 | ) 14 | : ( 15 | <> 16 | 17 | 18 | ); 19 | 20 | }; 21 | 22 | ContentDiff.propTypes = { 23 | sdk: PropTypes.shape({ 24 | field: PropTypes.shape({ 25 | getValue: PropTypes.func.isRequired, 26 | setValue: PropTypes.func.isRequired 27 | }), 28 | location: PropTypes.shape({ 29 | is: PropTypes.func.isRequired 30 | }) 31 | }).isRequired 32 | }; 33 | 34 | export default ContentDiff; -------------------------------------------------------------------------------- /src/extensions/FormBuilder/utils.js: -------------------------------------------------------------------------------- 1 | export function safeParse(maybeJson) { 2 | let parsed; 3 | try { 4 | parsed = JSON.parse(maybeJson); 5 | 6 | // We don't need to save empty dependsOn 7 | if (Object.keys(parsed).length < 1) { 8 | return undefined; 9 | } 10 | 11 | return parsed; 12 | } catch (error) { 13 | // pass 14 | 15 | return undefined; 16 | } 17 | } 18 | 19 | export const showModal = (sdk, modalProps = {}, parameters = {}) => { 20 | const { width = 'fullWidth', name } = modalProps; 21 | 22 | return sdk.dialogs.openExtension({ 23 | width, 24 | id: sdk.ids.extension, 25 | allowHeightOverflow: false, 26 | shouldCloseOnOverlayClick: true, 27 | shouldCloseOnEscapePress: true, 28 | position: 'center', 29 | parameters: { 30 | modal: name, 31 | ...parameters 32 | } 33 | }); 34 | }; 35 | 36 | export default { showModal, safeParse }; 37 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FormInfo/Providers/Redirect.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { FieldGroup, FormLabel, TextInput } from '@contentful/forma-36-react-components'; 4 | 5 | function Redirect({ formConfig }) { 6 | return ( 7 | <> 8 | 9 | URL 10 | formConfig.setUrl(e.currentTarget.value)} 16 | /> 17 | 18 | 19 | ); 20 | } 21 | 22 | Redirect.propTypes = { 23 | formConfig: PropTypes.shape({ 24 | url: PropTypes.string, 25 | type: PropTypes.string, 26 | setUrl: PropTypes.func, 27 | setType: PropTypes.func 28 | }).isRequired 29 | }; 30 | 31 | Redirect.defaultProps = {}; 32 | 33 | export default Redirect; 34 | -------------------------------------------------------------------------------- /src/shared/helpers/utility.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _ from 'lodash'; 3 | 4 | const updateJson = (json, name, value) => { 5 | return { 6 | ...json, 7 | [name]: value 8 | }; 9 | }; 10 | 11 | const getError = (error, message, position) => { 12 | return error ? ( 13 |
16 | {message} 17 |
18 | ) : null; 19 | }; 20 | 21 | const getInfo = (message) => { 22 | return ( 23 |
26 | {message} 27 |
28 | ); 29 | }; 30 | 31 | const hasDuplicate = (jsonObject, newName, oldName) => { 32 | return _.keys(jsonObject) 33 | .filter(key => key !== oldName) 34 | .some(key => newName.toUpperCase() === key.toUpperCase()); 35 | }; 36 | 37 | export { 38 | updateJson, 39 | getError, 40 | hasDuplicate, 41 | getInfo 42 | }; 43 | -------------------------------------------------------------------------------- /src/extensions/RecipeSteps/RecipeSteps.scss: -------------------------------------------------------------------------------- 1 | #add-table-row-wrap { 2 | margin-top: 1rem; 3 | } 4 | 5 | table.steps-table { 6 | margin-top: 1rem; 7 | 8 | tr { 9 | td { 10 | padding-top: 0.5rem; 11 | padding-bottom: 0.5rem; 12 | vertical-align: middle; 13 | 14 | &.col-actions { 15 | width: 1%; 16 | white-space: nowrap; 17 | 18 | * { 19 | visibility: hidden; 20 | } 21 | } 22 | } 23 | 24 | &:hover { 25 | td.col-actions { 26 | * { 27 | visibility: visible; 28 | } 29 | } 30 | } 31 | } 32 | 33 | th { 34 | font-weight: bold; 35 | } 36 | } 37 | 38 | #dialog-step-wrap { 39 | margin: 2rem; 40 | } 41 | 42 | #form-actions-row { 43 | * { 44 | text-align: center; 45 | } 46 | } -------------------------------------------------------------------------------- /src/shared/modules/getWorkflowState.js: -------------------------------------------------------------------------------- 1 | const isDraft = (entity) => { 2 | return !entity.sys.publishedVersion; 3 | }; 4 | 5 | const isChanged = (entity) => { 6 | return !!entity.sys.publishedVersion && 7 | entity.sys.version >= entity.sys.publishedVersion + 2; 8 | }; 9 | 10 | const isPublished = (entity) => { 11 | return !!entity.sys.publishedVersion && 12 | entity.sys.version === entity.sys.publishedVersion + 1; 13 | }; 14 | 15 | const isArchived = (entity) => { 16 | return !!entity.sys.archivedVersion; 17 | }; 18 | 19 | const getWorkflowState = (entity) => { 20 | if(isArchived(entity)) return 'archived'; 21 | if(isDraft(entity)) return 'draft'; 22 | if(isChanged(entity)) return 'changed'; 23 | if(isPublished(entity)) return 'published'; 24 | return null; 25 | }; 26 | 27 | export { 28 | isDraft, 29 | isChanged, 30 | isPublished, 31 | isArchived, 32 | getWorkflowState, 33 | }; 34 | 35 | 36 | export default getWorkflowState; 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/extensions/Seo/Seo.scss: -------------------------------------------------------------------------------- 1 | .seo-config { 2 | margin: 20px auto; 3 | width: 90%; 4 | } 5 | 6 | .tab-panel { 7 | padding: 10px; 8 | } 9 | 10 | .fieldset { 11 | margin-top: 10px; 12 | } 13 | 14 | .search-preview { 15 | font-family: arial,sans-serif; 16 | line-height: 1.57; 17 | font-size: small; 18 | font-weight: normal; 19 | margin: 0; 20 | & a { 21 | text-decoration: none; 22 | } 23 | & h3 { 24 | font-size: 20px; 25 | line-height: 1.3; 26 | display: inline-block; 27 | margin: 0; 28 | padding: 0; 29 | color: #1a0dab; 30 | } 31 | & .cite { 32 | padding-bottom: 0px; 33 | padding-top: 1px; 34 | display: inline-block; 35 | & cite { 36 | font-size: 16px; 37 | padding-top: 1px; 38 | line-height: 1.5; 39 | color: #006621; 40 | font-style: normal; 41 | } 42 | } 43 | & .description { 44 | color: #545454; 45 | max-width: 48em; 46 | font-size: 14px; 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /src/extensions/BynderImage/mockSdk.js: -------------------------------------------------------------------------------- 1 | export const mockBynderData = [ 2 | { 3 | id: 12345678, 4 | description: "fake image description", 5 | name: "fake image" 6 | } 7 | ]; 8 | 9 | class fakeField { 10 | constructor() { 11 | this._value = ""; 12 | } 13 | setValue(v) { 14 | this._value = v; 15 | } 16 | getValue() { 17 | return this._value; 18 | } 19 | onValueChanged = callback => { 20 | this._callback = callback; 21 | }; 22 | removeValue() { 23 | this._value = "REMOVED"; 24 | } 25 | } 26 | 27 | export function createMockSDK() { 28 | return { 29 | window: { 30 | startAutoResizer: () => {} 31 | }, 32 | field: new fakeField(), 33 | entry: { 34 | fields: { 35 | bynderId: new fakeField(), 36 | imageName: new fakeField(), 37 | altText: new fakeField(), 38 | altTextOverride: new fakeField(), 39 | internalTitle: new fakeField(), 40 | bynderData: new fakeField() 41 | } 42 | } 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/useFormConfig.js: -------------------------------------------------------------------------------- 1 | import useFormSteps from './useFormSteps'; 2 | import useProviderConfig from './useProviderConfig'; 3 | 4 | import { ensureIds } from './utils'; 5 | 6 | /** 7 | * Wrapper over other hooks which provide most of our form builder functionality 8 | * These functionalities include adding, editing, deleting and reordering steps & fields. 9 | * 10 | * the useProviderConfig is our main form configuration 11 | * */ 12 | export default function useFormConfig(handleFieldChange, initialState = {}) { 13 | const stepConfig = useFormSteps(handleFieldChange, initialState); 14 | const formConfig = useProviderConfig(handleFieldChange, initialState); 15 | 16 | const loadState = ({ steps = [], provider = {} }) => { 17 | if (steps && steps.length > 0) { 18 | stepConfig.stepsUpdate(ensureIds(steps)); 19 | } 20 | 21 | if (provider && Object.keys(provider).length > 0) { 22 | formConfig.update(provider); 23 | } 24 | }; 25 | 26 | return { formConfig, stepConfig, loadState }; 27 | } 28 | -------------------------------------------------------------------------------- /src/extensions/ContentDiff/constants.js: -------------------------------------------------------------------------------- 1 | export const entryWrapTestId = 'cdd-entry-wrap'; 2 | export const entryLabelTestId = 'cdd-entry-label'; 3 | export const entryValueTestId = 'cdd-entry-value'; 4 | export const arrayWrapTestId = 'cdd-array-wrap'; 5 | export const arrayLabelTestId = 'cdd-array-label'; 6 | export const arrayListTestId = 'cdd-array-list'; 7 | export const arrayListItemTestId = 'cdd-array-list-item'; 8 | 9 | export const firstIndex = 0; 10 | 11 | export const fieldTypes = { 12 | richText: 'RichText', 13 | symbol: 'Symbol', 14 | object: 'Object', 15 | array: 'Array', 16 | link: 'Link', 17 | text: 'Text', 18 | boolean: 'Boolean', 19 | }; 20 | 21 | export const linkTypes = { 22 | entry: 'Entry', 23 | asset: 'Asset', 24 | }; 25 | 26 | export const richTextFieldTypes = { 27 | asset: 'embedded-asset-block', 28 | entry: 'embedded-entry-block', 29 | paragraph: 'paragraph', 30 | }; 31 | 32 | export const paragraphFieldTypes = { 33 | text: 'text', 34 | inlineEntry: 'embedded-entry-inline', 35 | hyperlink: 'hyperlink', 36 | }; 37 | -------------------------------------------------------------------------------- /src/extensions/ColorPicker/ColorPicker.scss: -------------------------------------------------------------------------------- 1 | ul.color-choice-list { 2 | list-style: none; 3 | display: flex; 4 | flex-wrap: wrap; 5 | align-content: space-around; 6 | } 7 | 8 | li.color-choice-wrap { 9 | margin: 0.25rem 0.5rem; 10 | border-radius: 0.25rem; 11 | } 12 | 13 | button.btn.color-choice { 14 | color: transparent; 15 | border: 2px solid #fff; 16 | border-radius: inherit; 17 | mix-blend-mode: difference; 18 | position: relative; 19 | min-width: 6rem; 20 | min-height: 2rem; 21 | 22 | &:focus { 23 | outline-width: 0; 24 | } 25 | 26 | &:hover, 27 | &.active { 28 | border: 2px solid #fff !important; 29 | color: #fff; 30 | } 31 | 32 | &.active { 33 | color: transparent; 34 | 35 | &::before { 36 | content: '\2713'; 37 | color: #fff; 38 | font-weight: bold; 39 | font-size: 1.5rem; 40 | position: absolute; 41 | display: flex; 42 | align-items: center; 43 | justify-content: center; 44 | width: 100%; 45 | height: 100%; 46 | top: 0; 47 | left: 0; 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /src/extensions/PhoneNumber/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: Phone Number 2 | 3 | The Last Rev Phone Number extension is a field extension that saves phone numbers with extensions and a label. 4 | 5 | ## Setup Instructions 6 | 7 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 8 | 2. Create a new UI Extension in your space and choose these following Options: 9 | - Name: Phone Number 10 | - Field Types: Symbol 11 | - Hosting: Self-hosted(src) 12 | - Self-Hosted URL: [https://your-extension-domain.netlify.com/phone-number](https://your-extension-domain.netlify.com/phone-number) 13 | 3. Create a text field in your content model you want to use the phone number field 14 | 4. On the Content Model page, select "Settings" on the new text field you added 15 | 5. Go to Appearance and select your new UI Extension 16 | 17 | ## Reporting Issues 18 | 19 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! 20 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/install.http: -------------------------------------------------------------------------------- 1 | PUT https://api.contentful.com/spaces/4yx69hifndy8/environments/jaime-test-coveo-uie/extensions/coveo-search 2 | Authorization: Bearer 3 | Content-Type: application/vnd.contentful.management.v1+json 4 | 5 | { 6 | "extension": { 7 | "src": "http://localhost:3000/coveo-search", 8 | "name": "Coveo Search", 9 | "fieldTypes": [ 10 | { 11 | "type": "Array", 12 | "items": { 13 | "type": "Link", 14 | "linkType": "Entry" 15 | } 16 | }, 17 | { 18 | "type": "Object" 19 | } 20 | ], 21 | "sidebar": false, 22 | "parameters": { 23 | "installation": [ 24 | { 25 | "id": "endpoint", 26 | "name": "Endpoint URL", 27 | "type": "Symbol", 28 | "required": true 29 | } 30 | ], 31 | "instance": [ 32 | { 33 | "id": "searchPageName", 34 | "name": "Name of the Coveo Seach Page to use", 35 | "type": "Symbol", 36 | "required": true 37 | } 38 | ] 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/extensions/PersonName/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: Person Name 2 | 3 | The Last Rev Person Name extension is a field extension that saves a persons salutation, first name, middle name, last name, suffix, and nickname. 4 | 5 | 6 | ## Setup Instructions 7 | 8 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 9 | 2. Create a new UI Extension in your space and choose these following Options: 10 | - Name: Person Name 11 | - Field Types: Symbol 12 | - Hosting: Self-hosted(src) 13 | - Self-Hosted URL: [https://your-extension-domain.netlify.com/person-name](https://your-extension-domain.netlify.com/person-name) 14 | 3. Create a text field in your content model you want to use the phone number field 15 | 4. On the Content Model page, select "Settings" on the new text field you added 16 | 5. Go to Appearance and select your new UI Extension 17 | 18 | ## Reporting Issues 19 | 20 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FormBuilder.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | margin: 1rem; 3 | } 4 | 5 | .title { 6 | margin-bottom: 1rem; 7 | } 8 | 9 | .create-form-actions { 10 | display: flex; 11 | width: 100%; 12 | align-items: center; 13 | justify-content: flex-end; 14 | } 15 | 16 | .setup-form { 17 | .title { 18 | margin-bottom: 1rem; 19 | } 20 | .actions { 21 | width: 100%; 22 | margin-top: 1rem; 23 | display: flex; 24 | align-items: center; 25 | justify-content: flex-start; 26 | } 27 | } 28 | 29 | .sortable-list { 30 | padding-inline-start: 0px; 31 | } 32 | 33 | .card-item-content { 34 | display: flex; 35 | align-items: center; 36 | width: 100%; 37 | } 38 | 39 | .card-item-title { 40 | flex: 1; 41 | margin-left: 1rem; 42 | margin-right: 1rem; 43 | } 44 | 45 | .card-item-button { 46 | margin-right: 1rem; 47 | } 48 | 49 | .confirm-delete-dialog-form { 50 | margin-top: 1rem; 51 | } 52 | 53 | .confirm-delete-dialog-actions { 54 | display: flex; 55 | align-items: center; 56 | justify-content: flex-end; 57 | } 58 | 59 | .confirm-delete-dialog-button { 60 | margin-left: 1rem; 61 | } 62 | -------------------------------------------------------------------------------- /src/extensions/RecipeSteps/RecipeSteps.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { locations } from 'contentful-ui-extensions-sdk'; 4 | import StepList from './StepList'; 5 | import './RecipeSteps.scss'; 6 | import { StepDialog, BulkEditSteps } from './dialogs'; 7 | 8 | const renderDialog = (sdk) => { 9 | const { dialogType } = sdk.parameters.invocation; 10 | switch (dialogType) { 11 | case 'bulk-edit': 12 | return ; 13 | default: 14 | return ; 15 | } 16 | }; 17 | 18 | const RecipeSteps = ({ sdk }) => { 19 | return !sdk.location.is(locations.LOCATION_DIALOG) ? ( 20 | <> 21 | 22 | 23 | ) : ( 24 | <>{renderDialog(sdk)} 25 | ); 26 | }; 27 | 28 | RecipeSteps.propTypes = { 29 | sdk: PropTypes.shape({ 30 | field: PropTypes.shape({ 31 | getValue: PropTypes.func.isRequired, 32 | setValue: PropTypes.func.isRequired 33 | }), 34 | location: PropTypes.shape({ 35 | is: PropTypes.func.isRequired 36 | }) 37 | }).isRequired 38 | }; 39 | 40 | export default RecipeSteps; 41 | -------------------------------------------------------------------------------- /src/extensions/ContentDiff/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: Content Diff 2 | 3 | The Last Rev Content Diff extension can be used to show changes between content model versions one level deep in certain field types. 4 | 5 | Working Field Types: 6 | RichText, 7 | Symbol, 8 | Array, 9 | Text, 10 | Link 11 | 12 | Non-Working Field Types: 13 | Object 14 | 15 | ## Setup Instructions 16 | 17 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 18 | 2. Create a new UI Extension in your space and choose these following Options: 19 | - Name: Content Diff 20 | - Field Types: Object 21 | - Hosting: Self-hosted(src) 22 | - Self-Hosted URL: [https://your-extension-domain.netlify.com/seo](https://your-extension-domain.netlify.com/content-diff) 23 | 3. Go to an existing content model and select the Sidebar tab 24 | 4. On the Sidebar configuration page, select "Use custom sidebar" and add the Content Diff UI Extension 25 | 26 | ## Reporting Issues 27 | 28 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! 29 | -------------------------------------------------------------------------------- /src/extensions/RecipeIngredients/RecipeIngredients.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { locations } from 'contentful-ui-extensions-sdk'; 4 | import IngredientsList from './IngredientsList'; 5 | import './RecipeIngredients.scss'; 6 | import { IngredientDialog, BulkEditIngredients } from './dialogs'; 7 | 8 | const renderDialog = (sdk) => { 9 | const { dialogType } = sdk.parameters.invocation; 10 | switch (dialogType) { 11 | case 'bulk-edit': 12 | return ; 13 | default: 14 | return ; 15 | } 16 | }; 17 | const RecipeIngredients = ({ sdk }) => { 18 | return !sdk.location.is(locations.LOCATION_DIALOG) ? ( 19 | <> 20 | 21 | 22 | ) : ( 23 | <>{renderDialog(sdk)} 24 | ); 25 | }; 26 | 27 | RecipeIngredients.propTypes = { 28 | sdk: PropTypes.shape({ 29 | field: PropTypes.shape({ 30 | getValue: PropTypes.func.isRequired, 31 | setValue: PropTypes.func.isRequired 32 | }), 33 | location: PropTypes.shape({ 34 | is: PropTypes.func.isRequired 35 | }) 36 | }).isRequired 37 | }; 38 | 39 | export default RecipeIngredients; 40 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoSearch.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-is-valid */ 2 | import React from "react"; 3 | import "@contentful/forma-36-react-components/dist/styles.css"; 4 | import PropTypes from "prop-types"; 5 | import { locations } from "contentful-ui-extensions-sdk"; 6 | import CoveoSearchFieldDisplay from "./CoveoSearchFieldDisplay"; 7 | import CoveoSearchDialog from "./CoveoSearchDialog"; 8 | 9 | function CoveoSearch({ sdk }) { 10 | const { location } = sdk; 11 | 12 | if (location.is(locations.LOCATION_DIALOG)) { 13 | return ; 14 | } 15 | 16 | return ; 17 | } 18 | 19 | CoveoSearch.propTypes = { 20 | sdk: PropTypes.shape({ 21 | location: PropTypes.string.isRequired 22 | }).isRequired, 23 | locations: PropTypes.shape({ 24 | LOCATION_ENTRY_FIELD: PropTypes.string.isRequired, 25 | LOCATION_ENTRY_FIELD_SIDEBAR: PropTypes.string.isRequired, 26 | LOCATION_ENTRY_SIDEBAR: PropTypes.string.isRequired, 27 | LOCATION_DIALOG: PropTypes.string.isRequired, 28 | LOCATION_ENTRY_EDITOR: PropTypes.string.isRequired, 29 | LOCATION_PAGE: PropTypes.string.isRequired, 30 | LOCATION_APP_CONFIG: PropTypes.string.isRequired 31 | }).isRequired 32 | }; 33 | 34 | export default CoveoSearch; 35 | -------------------------------------------------------------------------------- /src/shared/components/TimezoneDropdown/TimezoneDropdown.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { getSelect } from '../../helpers'; 4 | 5 | const TIMEZONES = { 6 | 'America/New_York': 'New York', 7 | 'America/Chicago': 'Chicago', 8 | 'America/Denver': 'Denver', 9 | 'America/Phoenix': 'Phoenix', 10 | 'America/Los_Angeles': 'Los Angeles' 11 | }; 12 | 13 | function TimezoneDropdown({ id, value, onChange, disabled, position, name, className }) { 14 | return ( 15 | <> 16 | { 17 | getSelect( 18 | Object.keys(TIMEZONES), 19 | onChange, 20 | { id, name, disabled, optionObject: TIMEZONES }, 21 | value, 22 | position, 23 | className 24 | ) 25 | } 26 | 27 | ); 28 | } 29 | 30 | TimezoneDropdown.propTypes = { 31 | id: PropTypes.string, 32 | value: PropTypes.string, 33 | onChange: PropTypes.func, 34 | disabled: PropTypes.bool, 35 | position: PropTypes.string.isRequired, 36 | name: PropTypes.string, 37 | className: PropTypes.string, 38 | }; 39 | 40 | TimezoneDropdown.defaultProps = { 41 | id: '', 42 | className: '', 43 | disabled: false, 44 | name: '', 45 | onChange: () => {}, 46 | value: '', 47 | }; 48 | 49 | export default TimezoneDropdown; 50 | -------------------------------------------------------------------------------- /src/extensions/ContentDiff/ContentDiffSidebar.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger */ 2 | // import React from 'react'; 3 | import { configure } from '@testing-library/react'; 4 | 5 | import { resetLookups } from './helpers'; 6 | 7 | configure({ 8 | testIdAttribute: 'data-test-id', 9 | }); 10 | 11 | afterEach(() => { 12 | resetLookups(); 13 | }); 14 | 15 | describe('', () => { 16 | describe('useEffect()', () => { 17 | test.todo('test initial load'); 18 | }); 19 | 20 | describe('refresh()', () => { 21 | test.todo('test refresh fireEvent'); 22 | }); 23 | 24 | describe('onButtonClick()', () => { 25 | test.todo('test onButtonClick fireEvent'); 26 | }); 27 | 28 | describe('getDropdownAndButton()', () => { 29 | test.todo('test getDropdownAndButton is rendered with correct information'); 30 | }); 31 | 32 | describe('getLoadedInfo()', () => { 33 | test.todo('test when versions length is <= 0'); 34 | test.todo('test when versions length is > 0'); 35 | }); 36 | 37 | describe('getVersion()', () => { 38 | test.todo('test getVersion is fired when a version is selected'); 39 | }); 40 | 41 | describe('getOptions(options)', () => { 42 | test.todo('test getOptions to make sure correct information is rendered'); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/extensions/LocaleZooms/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: LocaleZooms 2 | 3 | The Last Rev LocaleZooms extension can be used to create a simple JSON object to store different locales with zoom ids. You can add, edit, and/or delete as many JSON fields as you would like. 4 | 5 | ## Setup Instructions 6 | 7 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 8 | 2. Create a new UI Extension in your space and choose these following Options: 9 | - Name: LocaleZooms 10 | - Field Types: Object 11 | - Hosting: Self-hosted(src) 12 | - Self-Hosted URL: [https://your-extension-domain.netlify.com/locale-zooms](https://your-extension-domain.netlify.com/locale-zooms) 13 | 3. Create an Object (JSON) field in your content model where you want to use the LocaleZooms field 14 | 4. On the Content Model page, select "Settings" on the new JSON field you added 15 | 5. Go to Appearance and select your new UI Extension 16 | 17 | ## Output Example 18 | 19 | ```json 20 | { 21 | "en-US": "9876543210", 22 | "es": "1234567890" 23 | } 24 | ``` 25 | 26 | ## Reporting Issues 27 | 28 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! -------------------------------------------------------------------------------- /src/extensions/RecipeIngredients/mockSdk.js: -------------------------------------------------------------------------------- 1 | const mockSdk = { 2 | field: { 3 | getValue: () => { 4 | return { 5 | ingredients: [ 6 | { 7 | 'imperialQuantity': 1, 8 | 'imperialMeasure': 'Cup', 9 | 'metricQuantity': 236, 10 | 'metricMeasure': 'Millimeter', 11 | 'ingredient': 'flour', 12 | 'step': 1, 13 | }, { 14 | 'imperialQuantity': 1, 15 | 'imperialMeasure': 'Wedge', 16 | 'metricQuantity': 1, 17 | 'metricMeasure': 'Wedge', 18 | 'ingredient': 'lemon', 19 | 'step': 2, 20 | } 21 | ] 22 | }; 23 | }, 24 | setValue: value => { 25 | return value; 26 | } 27 | }, 28 | location: { 29 | is: () => true, 30 | }, 31 | close: (value) => value, 32 | parameters: { 33 | invocation: { 34 | ingredient: { 35 | imperialQuantity: "1", 36 | imperialMeasure: "Cup", 37 | metricQuantity: "236", 38 | metricMeasure: "Millimeter", 39 | ingredient: "flour", 40 | step: "1", 41 | }, 42 | }, 43 | } 44 | }; 45 | 46 | export const mockSdkList = { 47 | ...mockSdk, 48 | location: { 49 | is: () => false, 50 | } 51 | }; 52 | 53 | export default mockSdk; 54 | -------------------------------------------------------------------------------- /src/extensions/Seo/__mocks__/mockFieldValue.js: -------------------------------------------------------------------------------- 1 | const mockFieldValue = { 2 | 'title': { 3 | name: 'title', 4 | value: 'Last Rev: Connecting the Modern Web' 5 | }, 6 | 'robots': { 7 | name: 'robots', 8 | value: 'index,follow' 9 | }, 10 | 'description': { 11 | name: 'description', 12 | value: 'some description' 13 | }, 14 | 'keywords': { 15 | name: 'keywords', 16 | value: 'These, are my, keywords' 17 | }, 18 | 'canonical': { 19 | name: 'canonical', 20 | value: 'https://www.lastrev.com' 21 | }, 22 | 'og:title': { 23 | name: 'og:title', 24 | value: 'Social Sharing Title' 25 | }, 26 | 'og:description': { 27 | name: 'og:description', 28 | value: 'Social Sharing Description' 29 | }, 30 | 'og:image': { 31 | name: 'og:image', 32 | value: { 33 | id: '1234asdf', 34 | title: 'test', 35 | url: '//placehold.it/600x315' 36 | } 37 | }, 38 | 'twitter:title': { 39 | name: 'twitter:title', 40 | value: 'Twitter Title' 41 | }, 42 | 'twitter:description': { 43 | name: 'twitter:description', 44 | value: 'Twitter Description' 45 | }, 46 | 'twitter:image': { 47 | name: 'og:image', 48 | value: { 49 | id: '1234asdf', 50 | title: 'test', 51 | url: '//placehold.it/600x315' 52 | } 53 | } 54 | }; 55 | 56 | export default mockFieldValue; 57 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FormInfo/Providers/Hubspot.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { FieldGroup, FormLabel, TextInput } from '@contentful/forma-36-react-components'; 4 | 5 | function Hubspot({ formConfig }) { 6 | return ( 7 | <> 8 | 9 | Form ID 10 | formConfig.setFormId(e.currentTarget.value)} 16 | /> 17 | 18 | 19 | Portal ID 20 | formConfig.setPortalId(e.currentTarget.value)} 26 | /> 27 | 28 | 29 | ); 30 | } 31 | 32 | Hubspot.propTypes = { 33 | formConfig: PropTypes.shape({ 34 | formId: PropTypes.string, 35 | portalId: PropTypes.string, 36 | type: PropTypes.string, 37 | 38 | setFormId: PropTypes.func, 39 | setPortalId: PropTypes.func, 40 | setType: PropTypes.func 41 | }).isRequired 42 | }; 43 | 44 | Hubspot.defaultProps = {}; 45 | 46 | export default Hubspot; 47 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FieldModal/AdditionalFields/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | 3 | import React from 'react'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import Select from './Select'; 7 | import Hidden from './Hidden'; 8 | import Toggleable from './Toggleable'; 9 | 10 | function AdditionalFields({ field, updateField, ...props }) { 11 | const { type } = field; 12 | switch (type) { 13 | case 'hidden': 14 | return ; 15 | case 'select': 16 | return ; 19 | case 'toggleable': 20 | return ; 21 | default: 22 | return null; 23 | } 24 | } 25 | 26 | AdditionalFields.propTypes = { 27 | updateField: PropTypes.func.isRequired, 28 | field: PropTypes.shape({ 29 | type: PropTypes.string 30 | }).isRequired, 31 | 32 | // eslint-disable-next-line react/forbid-prop-types 33 | errors: PropTypes.object 34 | }; 35 | 36 | AdditionalFields.defaultProps = { 37 | errors: [] 38 | }; 39 | 40 | export default AdditionalFields; 41 | -------------------------------------------------------------------------------- /src/extensions/RecipeSteps/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: RecipeSteps 2 | 3 | The Last Rev RecipeSteps extension can be used to create an array of JSON objects consisting of a title and a body. You can add, edit, and/or delete as many JSON objects as you would like. 4 | 5 | ## Setup Instructions 6 | 7 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 8 | 2. Create a new UI Extension in your space and choose these following Options: 9 | - Name: RecipeSteps 10 | - Field Types: Object 11 | - Hosting: Self-hosted(src) 12 | - Self-Hosted URL: [https://your-extension-domain.netlify.com/recipe-steps](https://your-extension-domain.netlify.com/recipe-steps) 13 | 3. Create an Object (JSON) field in your content model where you want to use the RecipeSteps field 14 | 4. On the Content Model page, select "Settings" on the new JSON field you added 15 | 5. Go to Appearance and select your new UI Extension 16 | 17 | ## Output Example 18 | 19 | ```json 20 | [ 21 | { 22 | "id": 1, 23 | "title": "Step 1", 24 | "body": "Step 1" 25 | }, 26 | { 27 | "id": 2, 28 | "title": "Step 2", 29 | "body": "Step 2" 30 | }, 31 | ] 32 | ``` 33 | 34 | ## Reporting Issues 35 | 36 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! 37 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FormInfo/Providers/Custom.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { FieldGroup, FormLabel, TextInput, Select, Option } from '@contentful/forma-36-react-components'; 4 | 5 | function Custom({ formConfig }) { 6 | return ( 7 | <> 8 | 9 | URL 10 | formConfig.setUrl(e.currentTarget.value)} 16 | /> 17 | 18 | 19 | Method 20 | 29 | 30 | 31 | ); 32 | } 33 | 34 | Custom.propTypes = { 35 | formConfig: PropTypes.shape({ 36 | url: PropTypes.string, 37 | method: PropTypes.string, 38 | type: PropTypes.string, 39 | setUrl: PropTypes.func, 40 | setType: PropTypes.func, 41 | setMethod: PropTypes.func 42 | }).isRequired 43 | }; 44 | 45 | Custom.defaultProps = {}; 46 | 47 | export default Custom; 48 | -------------------------------------------------------------------------------- /src/extensions/ColorPicker/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: ColorPicker 2 | 3 | The Last Rev Color Picker extension can be used anytime you want to give your users a finite list of colors to chose from. This can help to enforce brand guidelines while giving your content creators the ability to choose different colors. It uses the field validation options to disply the colors available 4 | 5 | ## Setup Instructions 6 | 7 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 8 | 2. Create a new UI Extension in your space and choose thos following Options: 9 | - Name: Color Picker 10 | - Field Types: Symbol 11 | - Hosting: Self-hosted(src) 12 | - Self-Hosted URL: [https://your-extension-domain.netlify.com/color-picker](https://your-extension-domain.netlify.com/color-picker) 13 | 3. Create a text field in your content model you want to use the color picker field 14 | 4. On the Content Model page, select "Settings" on the new text field you added 15 | 5. Go to Appearance and select your new UI Extension 16 | 6. Go to the Validations tab and select "Accept only specified values" and enter each color HEX value you want to allow. Be sure to include the # e.g. #FFFFFF or #000000 17 | 18 | ## Reporting Issues 19 | 20 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! 21 | -------------------------------------------------------------------------------- /src/extensions/ContentDiff/helpers/lookups.test.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@testing-library/react'; 2 | import { getEntryByDate, resetLookups } from './lookups'; 3 | import sdk, { entryOne, snapshotOne, snapshotTwo } from '../mockSdk'; 4 | 5 | configure({ 6 | testIdAttribute: 'data-test-id', 7 | }); 8 | 9 | sdk.space.getEntry = jest.fn(async () => entryOne); 10 | sdk.space.getEntrySnapshots = jest.fn(async () => ({ items: [snapshotOne, snapshotTwo] })); 11 | 12 | afterEach(() => { 13 | jest.clearAllMocks(); 14 | resetLookups(); 15 | }); 16 | 17 | describe('getEntryByDate(space, entryId, snapshotDate)', () => { 18 | test('returns entry object if a date is not passed in', async () => { 19 | const withoutDate = await getEntryByDate(sdk.space, entryOne.sys.id); 20 | expect(sdk.space.getEntry).toHaveBeenCalledTimes(1); 21 | expect(sdk.space.getEntry).toHaveBeenCalledWith(entryOne.sys.id); 22 | expect(sdk.space.getEntrySnapshots).not.toHaveBeenCalled(); 23 | expect(JSON.stringify(withoutDate)).toBe(JSON.stringify(entryOne)); 24 | }); 25 | 26 | test('returns snapshot object if a date is passed in', async () => { 27 | const withDate = await getEntryByDate(sdk.space, entryOne.sys.id, new Date(snapshotOne.sys.updatedAt)); 28 | expect(sdk.space.getEntry).not.toHaveBeenCalled(); 29 | expect(sdk.space.getEntrySnapshots).toHaveBeenCalledTimes(1); 30 | expect(sdk.space.getEntrySnapshots).toHaveBeenCalledWith(entryOne.sys.id); 31 | expect(JSON.stringify(withDate)).toBe(JSON.stringify(snapshotOne)); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/extensions/RecipeIngredients/helpers/selectValues.js: -------------------------------------------------------------------------------- 1 | const imperialUnits = [ 2 | '', 3 | 'Teaspoon', 4 | 'Teaspoons', 5 | 'Tablespoon', 6 | 'Tablespoons', 7 | 'Cup', 8 | 'Cups', 9 | 'Ounce', 10 | 'Ounces', 11 | 'Pound', 12 | 'Pounds', 13 | 'Gram', 14 | 'Grams', 15 | 'Gallon', 16 | 'Gallons', 17 | 'Pinch', 18 | 'Each', 19 | 'As needed', 20 | 'To serve', 21 | 'To Taste', 22 | 'Bunch', 23 | 'Bunches', 24 | 'Can', 25 | 'Cans', 26 | 'Clove', 27 | 'Cloves', 28 | 'Leaf', 29 | 'Leaves', 30 | 'Package', 31 | 'Packages', 32 | 'Recipe', 33 | 'Recipes', 34 | 'Rib', 35 | 'Ribs', 36 | 'Slice', 37 | 'Slices', 38 | 'Sprig', 39 | 'Sprigs', 40 | 'Wedge', 41 | 'Wedges', 42 | 'Head', 43 | 'Heads' 44 | ].sort(); 45 | 46 | const metricUnits = [ 47 | '', 48 | 'Teaspoon', 49 | 'Teaspoons', 50 | 'Tablespoon', 51 | 'Tablespoons', 52 | 'Milliliter', 53 | 'Milliliters', 54 | 'Liter', 55 | 'Liters', 56 | 'Ounce', 57 | 'Ounces', 58 | 'Gram', 59 | 'Grams', 60 | 'Kilo', 61 | 'Kilos', 62 | 'Pinch', 63 | 'Each', 64 | 'As needed', 65 | 'To serve', 66 | 'To Taste', 67 | 'Bunch', 68 | 'Bunches', 69 | 'Can', 70 | 'Cans', 71 | 'Clove', 72 | 'Cloves', 73 | 'Leaf', 74 | 'Leaves', 75 | 'Package', 76 | 'Packages', 77 | 'Recipe', 78 | 'Recipes', 79 | 'Rib', 80 | 'Ribs', 81 | 'Slice', 82 | 'Slices', 83 | 'Sprig', 84 | 'Sprigs', 85 | 'Wedge', 86 | 'Wedges', 87 | 'Head', 88 | 'Heads' 89 | ].sort(); 90 | 91 | export { imperialUnits, metricUnits }; 92 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/mockSdk.js: -------------------------------------------------------------------------------- 1 | let providers = [ 2 | { 3 | sys: { 4 | id: "mock-provider-1" 5 | } 6 | }, 7 | { 8 | sys: { 9 | id: "mock-provider-2" 10 | } 11 | } 12 | ]; 13 | 14 | let changeHandler = () => {}; 15 | 16 | const field = { 17 | getValue: () => { 18 | return providers; 19 | }, 20 | setValue: p => { 21 | providers = p; 22 | changeHandler(p); 23 | }, 24 | onValueChanged: handler => { 25 | changeHandler = handler; 26 | } 27 | }; 28 | 29 | const searchPageName = "contentful_uie_provider"; 30 | 31 | const endpoint = "https://microservices.uwhealth.dev/contentful-coveo-search"; 32 | const type = "Saved Search"; 33 | 34 | const mockSdk = { 35 | field, 36 | space: { 37 | getContentType: () => { 38 | return { 39 | displayField: "title", 40 | name: "provider" 41 | }; 42 | } 43 | }, 44 | parameters: { 45 | instance: { 46 | searchPageName 47 | }, 48 | installation: { 49 | endpoint, 50 | type 51 | }, 52 | invocation: { 53 | field, 54 | endpoint, 55 | type, 56 | searchPageName 57 | } 58 | }, 59 | location: { 60 | is: location => { 61 | // return location === 'entry-field'; 62 | return location === "dialog"; 63 | } 64 | }, 65 | dialogs: { 66 | openExtension: () => {} 67 | }, 68 | platformAlpha: { 69 | app: { 70 | onConfigure: () => {} 71 | } 72 | }, 73 | notifier: { 74 | error: window.alert 75 | } 76 | }; 77 | 78 | export default mockSdk; 79 | -------------------------------------------------------------------------------- /src/shared/components/StateDropdown/StateDropdown.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { FormLabel, Select, Option } from '@contentful/forma-36-react-components'; 4 | 5 | const states = ['AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY']; 6 | 7 | function StateDropdown({ className, name, labelText, value, onChange, required }) { 8 | return ( 9 | <> 10 | { 11 | labelText && { labelText } 12 | } 13 | 23 | 24 | ); 25 | } 26 | 27 | StateDropdown.propTypes = { 28 | className: PropTypes.string, 29 | name: PropTypes.string.isRequired, 30 | labelText: PropTypes.string, 31 | value: PropTypes.string, 32 | onChange: PropTypes.func, 33 | required: PropTypes.bool 34 | }; 35 | 36 | StateDropdown.defaultProps = { 37 | className: null, 38 | labelText: '', 39 | value: '', 40 | onChange: null, 41 | required: false 42 | }; 43 | 44 | export default StateDropdown; 45 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FormInfo/FormInfo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { FieldGroup, FormLabel, Select, Option } from '@contentful/forma-36-react-components'; 4 | 5 | import SectionWrapper from '../SectionWrapper'; 6 | 7 | import Custom from './Providers/Custom'; 8 | import Hubspot from './Providers/Hubspot'; 9 | import Redirect from './Providers/Redirect'; 10 | 11 | const mappings = { 12 | custom: Custom, 13 | hubspot: Hubspot, 14 | redirect: Redirect 15 | }; 16 | 17 | function FormInfo({ formConfig }) { 18 | const Provider = mappings[formConfig.type]; 19 | 20 | return ( 21 | 22 | 23 | Form Type 24 | 34 | 35 | {Provider && } 36 | 37 | ); 38 | } 39 | 40 | FormInfo.propTypes = { 41 | formConfig: PropTypes.shape({ 42 | formId: PropTypes.string, 43 | portalId: PropTypes.string, 44 | type: PropTypes.string, 45 | 46 | setFormId: PropTypes.func, 47 | setPortalId: PropTypes.func, 48 | setType: PropTypes.func 49 | }).isRequired 50 | }; 51 | 52 | FormInfo.defaultProps = {}; 53 | 54 | export default FormInfo; 55 | -------------------------------------------------------------------------------- /src/extensions/RecipeIngredients/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: RecipeIngredients 2 | 3 | The Last Rev RecipeIngredients extension can be used to create an array of JSON objects consisting of a title and a body. You can add, edit, and/or delete as many JSON objects as you would like. 4 | 5 | ## Setup Instructions 6 | 7 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 8 | 2. Create a new UI Extension in your space and choose these following Options: 9 | - Name: RecipeIngredients 10 | - Field Types: Object 11 | - Hosting: Self-hosted(src) 12 | - Self-Hosted URL: [https://your-extension-domain.netlify.com/recipe-ingredients](https://your-extension-domain.netlify.com/recipe-ingredients) 13 | 3. Create an Object (JSON) field in your content model where you want to use the RecipeIngredients field 14 | 4. On the Content Model page, select "Settings" on the new JSON field you added 15 | 5. Go to Appearance and select your new UI Extension 16 | 17 | ## Output Example 18 | 19 | ```json 20 | [ 21 | { 22 | "imperialQuantity": 1, 23 | "imperialMeasure": "Cup", 24 | "metricQuantity": 237, 25 | "metricMeasure": "Milliliters", 26 | "ingredient": "flour", 27 | "step": 1, 28 | }, 29 | { 30 | "imperialQuantity": 1, 31 | "imperialMeasure": "Cup", 32 | "metricQuantity": 237, 33 | "metricMeasure": "Milliliters", 34 | "ingredient": "milk", 35 | "step": 2, 36 | }, 37 | ] 38 | ``` 39 | 40 | ## Reporting Issues 41 | 42 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! 43 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoSearchResultList.js: -------------------------------------------------------------------------------- 1 | import { CheckboxField } from "@contentful/forma-36-react-components"; 2 | import { get, some } from "lodash"; 3 | /* eslint-disable react/prop-types */ 4 | import React from "react"; 5 | import { TYPE_REF_SEARCH } from "./constants"; 6 | 7 | function CoveoSearchResultList({ 8 | results = [], 9 | type, 10 | fieldMapping, 11 | selectReferenceHandler, 12 | selectedContent: selectedContentIds 13 | }) { 14 | const contentTypeKey = `raw.${get(fieldMapping, "contentType")}`; 15 | const contentIdKey = `raw.${get(fieldMapping, "contentId")}`; 16 | 17 | return results.length ? ( 18 |
19 | {results.map(result => { 20 | const title = get(result, "title"); 21 | const contentType = get(result, contentTypeKey); 22 | const contentId = get(result, contentIdKey); 23 | const id = `${contentType}|${contentId}`; 24 | const isChecked = some( 25 | selectedContentIds, 26 | selectedId => selectedId === contentId 27 | ); 28 | return ( 29 |
30 | {type === TYPE_REF_SEARCH ? ( 31 | selectReferenceHandler(contentId)} 36 | labelText={title} 37 | /> 38 | ) : ( 39 | {title} 40 | )} 41 |
42 | ); 43 | })} 44 |
45 | ) : ( 46 |
No results matching the search criteria
47 | ); 48 | } 49 | 50 | export default CoveoSearchResultList; 51 | -------------------------------------------------------------------------------- /src/extensions/RecipeIngredients/RecipeIngredients.scss: -------------------------------------------------------------------------------- 1 | #add-table-row-wrap { 2 | margin-top: 1rem; 3 | } 4 | 5 | table.steps-table { 6 | margin-top: 1rem; 7 | 8 | tr { 9 | td { 10 | padding-top: 0.5rem; 11 | padding-bottom: 0.5rem; 12 | vertical-align: middle; 13 | 14 | &.col-actions { 15 | width: 1%; 16 | white-space: nowrap; 17 | 18 | * { 19 | visibility: hidden; 20 | } 21 | } 22 | } 23 | 24 | &:hover { 25 | td.col-actions { 26 | * { 27 | visibility: visible; 28 | } 29 | } 30 | } 31 | } 32 | 33 | th { 34 | font-weight: bold; 35 | } 36 | } 37 | 38 | #dialog-step-wrap { 39 | margin: 2rem; 40 | } 41 | 42 | #form-actions-row { 43 | * { 44 | text-align: center; 45 | } 46 | } 47 | 48 | #bulk-editing{ 49 | margin: 1rem; 50 | height: 500px; 51 | width: 100%; 52 | } 53 | 54 | #bulk-editing th { 55 | position: sticky; 56 | top: 0; 57 | z-index: 1000; 58 | } 59 | 60 | #bulk-editing p { 61 | margin: 1rem auto; 62 | width: 800px; 63 | text-align: center; 64 | } 65 | 66 | #bulk-editing .action-buttons { 67 | padding: 1rem 2rem; 68 | position: fixed; 69 | bottom: 0; 70 | text-align: right; 71 | width: 100%; 72 | background-color: white; 73 | } 74 | 75 | #bulk-editing .action-buttons .bulk-submit-btn { 76 | margin-left: 2rem; 77 | 78 | } 79 | 80 | #bulk-editing .action-buttons .bulk-cancel-btn { 81 | 82 | } 83 | 84 | .bulk-step { 85 | width: 50px; 86 | } -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FieldModal/AdditionalFields/Hidden.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { TextField } from '@contentful/forma-36-react-components'; 4 | import { ErrorStyle } from '../../StepList/styles'; 5 | 6 | import { errorOfType, errorTypes } from '../../validate'; 7 | 8 | function extractValue({ value }) { 9 | if (typeof value === 'string') return value; 10 | return JSON.stringify(value); 11 | } 12 | 13 | function Hidden({ errors, updateField, field }) { 14 | const fieldErrors = errors[field.id]; 15 | return ( 16 | <> 17 | { 23 | let value; 24 | 25 | try { 26 | // Try to load JSON types (true/false) 27 | value = JSON.parse(e.currentTarget.value); 28 | } catch (error) { 29 | // Default to loading as text 30 | value = e.currentTarget.value; 31 | } 32 | 33 | updateField('value', value); 34 | }} 35 | /> 36 | {errorOfType(errorTypes.INVALID_VALUE, fieldErrors) && Hidden fields require a value} 37 | 38 | ); 39 | } 40 | 41 | Hidden.propTypes = { 42 | updateField: PropTypes.func.isRequired, 43 | field: PropTypes.shape({ 44 | id: PropTypes.string, 45 | value: PropTypes.oneOfType([PropTypes.bool.isRequired, PropTypes.string.isRequired]) 46 | }).isRequired, 47 | 48 | // eslint-disable-next-line react/forbid-prop-types 49 | errors: PropTypes.object 50 | }; 51 | 52 | Hidden.defaultProps = { 53 | errors: [] 54 | }; 55 | 56 | export default Hidden; 57 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/EditorModal/styles.js: -------------------------------------------------------------------------------- 1 | import { Card } from '@contentful/forma-36-react-components'; 2 | import styled from 'styled-components'; 3 | import { ModalStyle } from '../styles'; 4 | 5 | export const Col = styled.div` 6 | display: flex; 7 | flex-direction: column; 8 | `; 9 | 10 | export const Row = styled.div` 11 | display: flex; 12 | flex-direction: row; 13 | `; 14 | 15 | export const ActionSection = styled(Row)` 16 | padding: 0.5rem; 17 | justify-content: flex-end; 18 | border-top: 5px solid whitesmoke; 19 | position: relative; 20 | z-index: 100; 21 | 22 | button { 23 | margin-left: 1rem; 24 | } 25 | `; 26 | 27 | export const EditorStyle = styled(ModalStyle)` 28 | padding-bottom: 0; 29 | overflow: hidden; 30 | 31 | h1 { 32 | min-height: 2rem; // Keep styles aligned 33 | margin-bottom: 1rem; 34 | } 35 | `; 36 | 37 | export const SectionWrapper = styled(Row)` 38 | padding: 0; 39 | `; 40 | 41 | export const NothingHere = styled(Card)` 42 | margin-top: 3rem; 43 | min-height: 8rem; 44 | 45 | display: flex; 46 | align-items: center; 47 | justify-content: center; 48 | `; 49 | 50 | const maxModalHeight = '75vh'; 51 | 52 | export const LeftSection = styled(Col)` 53 | overflow-y: scroll; 54 | width: 50%; 55 | max-width: 500px; 56 | max-height: ${maxModalHeight}; 57 | overflow-y: scroll; 58 | 59 | padding-left: 1rem; 60 | padding-right: 0.5rem; 61 | padding-bottom: 2rem; 62 | `; 63 | 64 | export const RightSection = styled(Col)` 65 | width: 50%; 66 | flex-grow: 1; 67 | bottom: 0; 68 | 69 | max-height: ${maxModalHeight}; 70 | overflow-y: scroll; 71 | align-items: center; 72 | 73 | padding-left: 0.5rem; 74 | padding-right: 1rem; 75 | padding-bottom: 2rem; 76 | 77 | > div { 78 | width: 100%; 79 | } 80 | `; 81 | -------------------------------------------------------------------------------- /src/extensions/PhoneNumber/PhoneNumber.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { TextField } from "@contentful/forma-36-react-components"; 3 | import PropTypes from "prop-types"; 4 | 5 | export default function PhoneNumber({ sdk }) { 6 | const [fieldValue, setFieldValue] = useState({}); 7 | 8 | useEffect(() => { 9 | if (sdk.field.getValue()) { 10 | setFieldValue(sdk.field.getValue()); 11 | } else { 12 | setFieldValue({}); 13 | }; 14 | 15 | }, [sdk.field]); 16 | 17 | const handleFieldChange = (fieldName) => (e) => { 18 | fieldValue[fieldName] = e.currentTarget.value; 19 | sdk.field.setValue(fieldValue); 20 | setFieldValue(fieldValue); 21 | }; 22 | 23 | return ( 24 |
25 | 33 | 42 | 50 |
51 | ); 52 | } 53 | 54 | PhoneNumber.propTypes = { 55 | sdk: PropTypes.shape({ 56 | field: PropTypes.shape({ 57 | getValue: PropTypes.func.isRequired, 58 | setValue: PropTypes.func.isRequired, 59 | }), 60 | }).isRequired, 61 | }; 62 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoReferenceSearch/CoveoReferenceSearchEntry.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import { 3 | DropdownList, 4 | DropdownListItem, 5 | EntityListItem 6 | } from "@contentful/forma-36-react-components"; 7 | import { get } from "lodash"; 8 | import React, { useEffect, useState } from "react"; 9 | import { getWorkflowState } from "../../../shared/modules/getWorkflowState"; 10 | 11 | function CoveoReferenceSearchEntry({ space, contentId, removeHandler }) { 12 | const [title, setTitle] = useState(null); 13 | const [status, setStatus] = useState(null); 14 | const [loading, setLoading] = useState(true); 15 | const [contentType, setContentType] = useState(null); 16 | 17 | useEffect(() => { 18 | const load = async () => { 19 | const entry = await space.getEntry(contentId); 20 | const { displayField, name } = await space.getContentType( 21 | get(entry, "sys.contentType.sys.id") 22 | ); 23 | setTitle(get(entry, `fields.${displayField}.en-US`)); // TODO: locale? 24 | setStatus(getWorkflowState(entry)); 25 | setContentType(name); 26 | setLoading(false); 27 | }; 28 | load(); 29 | }, [space, contentId]); 30 | 31 | return ( 32 | 41 | Actions 42 | removeHandler(contentId)}> 43 | Remove 44 | 45 | 46 | } 47 | /> 48 | ); 49 | } 50 | 51 | export default CoveoReferenceSearchEntry; 52 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/useFieldConfig.js: -------------------------------------------------------------------------------- 1 | import { curry } from 'lodash/fp'; 2 | import arrayMove from 'array-move'; 3 | 4 | import { buildField } from './utils'; 5 | 6 | /** 7 | * We can pass in a single function (edit a step) 8 | * And use that function to generate a set of utility functions 9 | * for editing individual fields inside of that step 10 | * 11 | * onChange should be a function which takes a string and a 12 | * onChange('stepId', newStepValue) 13 | * */ 14 | export default function useFieldConfig(onChangeStep) { 15 | const fieldRemove = curry((stepId, field) => 16 | onChangeStep( 17 | stepId, 18 | 19 | // Filter out the step 20 | // we're passing a function to stepEdit which will give us 21 | // the latest version of the step (actomic update) 22 | (oldStep) => ({ 23 | ...oldStep, 24 | fields: oldStep.fields.filter(({ id: fieldId }) => fieldId !== field.id) 25 | }) 26 | ) 27 | ); 28 | 29 | // We want to keep this function curried so we 30 | // must provide a second argument (for lodash) 31 | // eslint-disable-next-line no-unused-vars 32 | const fieldAdd = curry((stepId) => { 33 | onChangeStep(stepId, (oldStep) => ({ 34 | ...oldStep, 35 | fields: oldStep.fields.concat(buildField()) 36 | })); 37 | }); 38 | 39 | const fieldEdit = curry((stepId, newField) => { 40 | onChangeStep(stepId, (oldStep) => ({ 41 | ...oldStep, 42 | fields: oldStep.fields.map((field) => (field.id === newField.id ? newField : field)) 43 | })); 44 | }); 45 | 46 | const fieldReorder = curry((stepId, { oldIndex, newIndex }) => { 47 | // Move the item to position requested 48 | onChangeStep(stepId, (step) => ({ ...step, fields: arrayMove(step.fields, oldIndex, newIndex) })); 49 | }); 50 | 51 | return { 52 | fieldAdd, 53 | fieldRemove, 54 | fieldEdit, 55 | fieldReorder 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/shared/components/DatePicker/DatePicker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | /* eslint-disable no-shadow */ 3 | /* eslint-disable react/no-this-in-sfc */ 4 | /* eslint-disable react/prefer-stateless-function */ 5 | 6 | import React from 'react'; 7 | import PropTypes from 'prop-types'; 8 | import { TextInput } from '@contentful/forma-36-react-components'; 9 | import ReactDatePicker from 'react-datepicker'; 10 | import 'react-datepicker/dist/react-datepicker.css'; 11 | 12 | function DatePicker({ name, placeholderText, selected, onChange, minDate, 13 | excludeDates, className }) { 14 | 15 | // using a class component because of a warning about using refs on functional component 16 | class CustomInput extends React.Component { 17 | render() { 18 | const { value, onClick, onChange } = this.props; 19 | return ( 20 | 26 | ); 27 | } 28 | } 29 | 30 | return ( 31 | } /> 40 | ); 41 | } 42 | 43 | DatePicker.propTypes = { 44 | name: PropTypes.string.isRequired, 45 | placeholderText: PropTypes.string, 46 | selected: PropTypes.shape(), // Date 47 | onChange: PropTypes.func, 48 | minDate: PropTypes.shape(), // Date 49 | excludeDates: PropTypes.arrayOf(PropTypes.shape()), 50 | className: PropTypes.string, 51 | }; 52 | 53 | DatePicker.defaultProps = { 54 | placeholderText: null, 55 | selected: null, 56 | onChange: null, 57 | minDate: null, 58 | excludeDates: null, 59 | className: null, 60 | }; 61 | 62 | export default DatePicker; 63 | -------------------------------------------------------------------------------- /src/extensions/ContentDiff/helpers/createHtml.js: -------------------------------------------------------------------------------- 1 | import { get, isUndefined } from 'lodash'; 2 | import { arrayLabelTestId, arrayWrapTestId, entryLabelTestId, entryValueTestId, entryWrapTestId } from '../constants'; 3 | import { getArrayValue, getValue } from './getters'; 4 | 5 | export const createHtmlForEntry = (entry) => { 6 | return `
  • 7 | 8 |

    ${getValue(entry)}

    9 |
  • `; 10 | }; 11 | 12 | export const createHtmlForAsset = (asset) => { 13 | if (!asset || isUndefined(get(asset, 'fields'))) return ''; 14 | 15 | return `
  • 16 | 21 |
    26 |
  • `; 27 | }; 28 | 29 | export const createHtmlForArray = (field) => { 30 | return `
  • 31 | 32 | ${getArrayValue(field)} 33 |
  • 34 | `; 35 | }; 36 | 37 | export const createAssetHtml = (asset) => { 38 | if (!asset || isUndefined(get(asset, 'fields'))) return ''; 39 | 40 | return `
    ${asset.fields.title['en-US']}
    41 |
      42 |
    • 43 |
    44 | `; 45 | }; 46 | -------------------------------------------------------------------------------- /src/extensions/OperatingHours/OperatingHours.scss: -------------------------------------------------------------------------------- 1 | .operatingHours { 2 | &__table { 3 | margin-top: 1rem; 4 | width: 99.5%; 5 | } 6 | 7 | &__timeRange { 8 | padding-right: 2rem; 9 | padding-top: 2rem; 10 | padding-bottom: 0; 11 | } 12 | 13 | &__table-actions { 14 | display: flex; 15 | justify-content: space-around; 16 | } 17 | 18 | &__datepicker { 19 | max-width: 120px; 20 | } 21 | 22 | &__isClosed { 23 | max-width: 50px; 24 | } 25 | 26 | &__timezone { 27 | max-width: 130px; 28 | } 29 | 30 | &__emptyTableRow { 31 | padding: .9375rem 1.25rem; 32 | text-align: center; 33 | } 34 | 35 | &__newRowError { 36 | margin-top: 2rem; 37 | } 38 | 39 | &__newRowCard { 40 | margin-top: 2rem; 41 | } 42 | 43 | &__cancelButton { 44 | margin-left: 1rem; 45 | } 46 | 47 | &__newRowForm > div:last-child { 48 | margin-bottom: 0; 49 | } 50 | 51 | &__newRowFormFields { 52 | display: flex; 53 | 54 | > div { 55 | margin-bottom: 0; 56 | margin-right: 2rem; 57 | 58 | &:last-child { 59 | flex: 1 0 auto; 60 | margin-right: 1rem; 61 | } 62 | } 63 | 64 | &__isClosed { 65 | display: flex; 66 | flex-direction: column-reverse; 67 | justify-content: space-between; 68 | 69 | > input { 70 | margin-bottom: 1rem; 71 | } 72 | } 73 | 74 | &__timeRange > label { 75 | margin-bottom: 1.5rem; 76 | } 77 | } 78 | 79 | &__dateTableCell { 80 | width: 130px; 81 | } 82 | 83 | &__timezoneTableCell { 84 | width: 120px; 85 | } 86 | 87 | &__switchTableCell { 88 | width: 90px; 89 | } 90 | 91 | &__actionsTableCell { 92 | width: 40px; 93 | } 94 | 95 | input[type=checkbox] { 96 | box-sizing: content-box; 97 | } 98 | 99 | tr > td { 100 | vertical-align: middle; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/extensions/Seo/metaDataOptions.js: -------------------------------------------------------------------------------- 1 | const metaDataOptions = [ 2 | { 3 | tag: 'select', 4 | value: 'robots', 5 | label: 'Search Engine Visibility', 6 | cssClass: 'data-default', 7 | help: 'Should search engines index this content?', 8 | options: [ 9 | { value: 'index,follow', label: 'Index' }, 10 | { value: 'noindex,nofollow', label: "Don't Index" } 11 | ] 12 | }, 13 | { 14 | tag: 'input', 15 | value: 'title', 16 | label: 'Page Title', 17 | cssClass: 'data-default', 18 | help: 'Browser tab and search engine result display.' 19 | }, 20 | { 21 | tag: 'textarea', 22 | value: 'description', 23 | label: 'Meta Description', 24 | cssClass: 'data-default', 25 | help: 'The short description in search engine result display.' 26 | }, 27 | { 28 | tag: 'textarea', 29 | value: 'keywords', 30 | label: 'Meta Keywords', 31 | help: 'Keywords used for search engine indexing.' 32 | }, 33 | { 34 | tag: 'input', 35 | value: 'canonical', 36 | label: 'Canonical URL', 37 | help: 'Canonical specifies to search engines your preferred URL. Default is current page URL.' 38 | }, 39 | { 40 | tag: 'input', 41 | value: 'og:title', 42 | label: 'Social Sharing Title', 43 | help: 'Used as the title when the content is shared on social networks.' 44 | }, 45 | { 46 | tag: 'textarea', 47 | value: 'og:description', 48 | label: 'Social Sharing Description', 49 | help: 'Used as the description when the content is shared on social networks.' 50 | }, 51 | { 52 | tag: 'image', 53 | value: 'og:image', 54 | label: 'Social Sharing Image', 55 | help: 'Used as the thumbnail when the content is shared on social networks.' 56 | }, 57 | { 58 | tag: 'image', 59 | value: 'twitter:image', 60 | label: 'Twitter Sharing Image', 61 | help: 'Used as the thumbnail when the content is shared on Twitter.' 62 | } 63 | ]; 64 | 65 | export default metaDataOptions; 66 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FieldModal/AdditionalFields/Toggleable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { curry } from 'lodash/fp'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import styled from 'styled-components'; 5 | import PropTypes from 'prop-types'; 6 | import { FieldGroup, CheckboxField } from '@contentful/forma-36-react-components'; 7 | 8 | import FieldEditor from '../FieldEditor'; 9 | 10 | const SubfieldStyle = styled.div` 11 | padding-left: 16px; 12 | border-left: 2px solid lightgrey; 13 | margin-bottom: 16px; 14 | 15 | > h1 { 16 | margin-bottom: 8px; 17 | } 18 | `; 19 | 20 | function makeDefaultField() { 21 | return { 22 | id: uuidv4(), 23 | name: 'name', 24 | type: 'hidden' 25 | }; 26 | } 27 | 28 | function Toggleable({ field, updateField }) { 29 | const { field: subfield = makeDefaultField() } = field; 30 | 31 | return ( 32 | 33 | 34 | { 37 | if (maybeKey instanceof Object) { 38 | updateField('field', maybeKey); 39 | return; 40 | } 41 | 42 | updateField('field', { 43 | ...subfield, 44 | [maybeKey]: newValue 45 | }); 46 | })} 47 | /> 48 | 49 | updateField('defaultValue', e.currentTarget.checked)} 55 | /> 56 | 57 | ); 58 | } 59 | 60 | Toggleable.propTypes = { 61 | updateField: PropTypes.func.isRequired, 62 | field: PropTypes.shape({ 63 | field: PropTypes.object, 64 | id: PropTypes.string, 65 | type: PropTypes.string, 66 | defaultValue: PropTypes.bool 67 | }).isRequired 68 | }; 69 | 70 | export default Toggleable; 71 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoSearchHooks.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { each, map } from "lodash"; 3 | 4 | export const useScripts = (js, scriptsLoadedHandler) => { 5 | useEffect(() => { 6 | if (!js || !js.length) return () => {}; 7 | 8 | const scripts = map(js, ({ URL, InlineContent }) => { 9 | const script = document.createElement("script"); 10 | 11 | if (URL && URL.length) { 12 | script.src = URL; 13 | } else if (InlineContent && InlineContent.length) { 14 | const inlineScript = document.createTextNode(InlineContent); 15 | script.appendChild(inlineScript); 16 | } 17 | 18 | return script; 19 | }); 20 | 21 | each(scripts, script => { 22 | document.body.appendChild(script); 23 | }); 24 | 25 | scriptsLoadedHandler(); 26 | 27 | return () => { 28 | each(scripts, script => { 29 | document.body.removeChild(script); 30 | }); 31 | }; 32 | }, [js, scriptsLoadedHandler]); 33 | }; 34 | 35 | export const useCss = css => { 36 | useEffect(() => { 37 | if (!css || !css.length) return () => {}; 38 | 39 | const elements = map(css, ({ URL, InlineContent }) => { 40 | let element; 41 | 42 | if (URL && URL.length) { 43 | element = document.createElement("link"); 44 | element.setAttribute("rel", "stylesheet"); 45 | element.type = "text/css"; 46 | element.href = URL; 47 | } else if (InlineContent && InlineContent.length) { 48 | element = document.createElement("style"); 49 | element.type = "text/css"; 50 | const inlineStyle = document.createTextNode(InlineContent); 51 | element.appendChild(inlineStyle); 52 | } 53 | 54 | return element; 55 | }); 56 | 57 | each(elements, element => { 58 | document.body.appendChild(element); 59 | }); 60 | 61 | return () => { 62 | each(elements, element => { 63 | document.body.removeChild(element); 64 | }); 65 | }; 66 | }, [css]); 67 | }; 68 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/StepModal/StepEditor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { curry } from 'lodash/fp'; 4 | import { Button, FieldGroup, FormLabel, TextInput } from '@contentful/forma-36-react-components'; 5 | 6 | import DependsOn from '../StepList/DependsOn'; 7 | import { useSDK } from '../../../context'; 8 | 9 | function StepEditor({ step, updateStep }) { 10 | const sdk = useSDK(); 11 | const updateStepEvent = curry((key, event) => updateStep(key, event.currentTarget.value)); 12 | 13 | const handleSubmit = () => sdk.close({ step }); 14 | const handleCancel = () => sdk.close({ step: null }); 15 | 16 | const { title = '' } = step; 17 | 18 | return ( 19 | <> 20 | 21 | Label 22 | 23 | 24 | 30 |
    31 |
    32 | 35 | 43 |
    44 |
    45 | 46 | ); 47 | } 48 | 49 | StepEditor.propTypes = { 50 | step: PropTypes.shape({ 51 | title: PropTypes.string, 52 | dependsOn: PropTypes.string, 53 | dependsOnTests: PropTypes.arrayOf(PropTypes.string) 54 | }).isRequired, 55 | updateStep: PropTypes.func.isRequired 56 | }; 57 | 58 | StepEditor.defaultProps = {}; 59 | 60 | export default StepEditor; 61 | -------------------------------------------------------------------------------- /src/__mocks__/mockContentfulSdk.js: -------------------------------------------------------------------------------- 1 | import mockContentfulAsset from './mockContentfulAsset'; 2 | import mockContentfulContentType from './mockContentfulContentType'; 3 | 4 | const sdk = { 5 | init: (mockFieldValue, mockAppConfig) => { 6 | return { 7 | field: { 8 | getValue: jest.fn().mockImplementation(() => { 9 | return { 10 | ...mockFieldValue, 11 | }; 12 | }) 13 | , 14 | setValue: jest.fn().mockResolvedValue({ 15 | 'hello': 'world', 16 | }), 17 | locale: 'en-US', 18 | }, 19 | dialogs: { 20 | selectSingleAsset: jest.fn().mockImplementation(() => { 21 | return new Promise((resolve, reject) => { 22 | resolve(mockContentfulAsset.success); 23 | reject(mockContentfulAsset.error); 24 | }); 25 | }), 26 | }, 27 | platformAlpha: { 28 | app: { 29 | getParameters: jest.fn().mockImplementation(async (appSettings) => { 30 | return { 31 | ...mockAppConfig, 32 | appSettings, 33 | }; 34 | }), 35 | onConfigure: jest.fn().mockImplementation((fn) => { 36 | fn(); 37 | }), 38 | setReady: jest.fn().mockImplementation(() => { 39 | return null; 40 | }) 41 | } 42 | }, 43 | space: { 44 | getContentTypes: jest.fn().mockImplementation(async () => { 45 | return { 46 | items: mockContentfulContentType.array, 47 | }; 48 | }), 49 | getAsset: jest.fn().mockImplementation((type) => { 50 | return new Promise((resolve) => { 51 | if(type === 'reject') { 52 | return resolve(mockContentfulAsset.error); 53 | } 54 | return resolve(mockContentfulAsset.success); 55 | }); 56 | }) 57 | }, 58 | location: { 59 | is: () => { 60 | return true; 61 | } 62 | } 63 | 64 | }; 65 | } 66 | }; 67 | 68 | export default sdk; -------------------------------------------------------------------------------- /src/extensions/ColorPicker/ColorPicker.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react'; 2 | import { each, has, isArray, get } from 'lodash'; 3 | import './ColorPicker.scss'; 4 | import PropTypes from 'prop-types'; 5 | 6 | const ColorPicker = ({ sdk }) => { 7 | const [fieldValue, setFieldValue] = useState(''); 8 | 9 | useEffect(() => { 10 | setFieldValue(sdk.field.getValue()); 11 | }, [sdk.field]); 12 | 13 | const getColorOptions = () => { 14 | const { validations } = sdk.field; 15 | const hexOptions = []; 16 | each(validations, (val) => { 17 | if (has(val, 'in') && isArray(get(val, 'in'))) { 18 | each(get(val, 'in'), (hex) => { 19 | hexOptions.push(hex); 20 | }); 21 | } 22 | }); 23 | return hexOptions; 24 | }; 25 | 26 | const handleColorChange = (e) => { 27 | const hex = e.currentTarget.getAttribute('data-hex'); 28 | sdk.field.setValue(hex); 29 | setFieldValue(hex); 30 | }; 31 | 32 | return ( 33 |
    34 |
      35 | {getColorOptions().map((hex, index) => ( 36 |
    • 40 |
    • 50 | ))} 51 |
    52 |
    53 | ); 54 | }; 55 | 56 | ColorPicker.propTypes = { 57 | sdk: PropTypes.shape({ 58 | field: PropTypes.shape({ 59 | getValue: PropTypes.func.isRequired, 60 | setValue: PropTypes.func.isRequired, 61 | validations: PropTypes.arrayOf(PropTypes.shape({ 62 | in: PropTypes.array, 63 | })) 64 | }) 65 | }).isRequired 66 | }; 67 | 68 | export default ColorPicker; 69 | -------------------------------------------------------------------------------- /src/extensions/ContentDiff/helpers/getters.js: -------------------------------------------------------------------------------- 1 | import { get, isArray, escape } from 'lodash'; 2 | import { fieldTypes, linkTypes, arrayListItemTestId, arrayListTestId } from '../constants'; 3 | 4 | export const getId = (field) => { 5 | return field.id; 6 | }; 7 | 8 | export const getType = (field) => { 9 | return field.type; 10 | }; 11 | 12 | export const getLabel = (field) => { 13 | return field.label; 14 | }; 15 | 16 | export const getArrayType = (field) => { 17 | return field.arrayType; 18 | }; 19 | 20 | export const getTextValue = (field) => { 21 | let value = escape(field.value) 22 | .replace('<code>', '') 23 | .replace('</code>', ''); 24 | if (field.textType === 'markdown' || field.textType === 'multipleLine' || value.indexOf('') > -1) { 25 | value = `
    ${value}
    `; 26 | } 27 | return value; 28 | }; 29 | 30 | export const getValue = (field) => { 31 | let value; 32 | if (field) { 33 | if (field.type === fieldTypes.symbol || field.type === fieldTypes.text) { 34 | value = getTextValue(field); 35 | } else if (field.type === fieldTypes.link && field.linkType === linkTypes.asset) { 36 | value = get(field, 'asset'); 37 | } else if (field.type === fieldTypes.link && field.linkType === linkTypes.entry) { 38 | const displayField = get(field, 'entryContentType.displayField'); 39 | value = get(field, `entry.fields['${displayField}']['en-US']`); 40 | } else if (field.type === fieldTypes.boolean) { 41 | value = get(field, 'value') ? 'True' : 'False'; 42 | } else { 43 | value = get(field, 'value'); 44 | } 45 | } 46 | return value; 47 | }; 48 | 49 | export const getArrayValue = (arrayField) => { 50 | const values = isArray(arrayField) ? arrayField : get(arrayField, 'value', []); 51 | if (!values.length) return ''; 52 | const arrayValues = values 53 | .map((value) => `
  • ${value}
  • `) 54 | .join(''); 55 | return `
      ${arrayValues}
    `; 56 | }; 57 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/useFieldConfig.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks'; 2 | import useFieldConfig from './useFieldConfig'; 3 | 4 | import { getStepsStateShim } from './test.helpers'; 5 | 6 | describe('useFieldConfig', () => { 7 | const defaultSteps = [ 8 | { 9 | id: 'step-id', 10 | fields: [ 11 | // 12 | { id: 'first-id' }, 13 | { id: 'second-id' } 14 | ] 15 | } 16 | ]; 17 | 18 | function getFields({ steps }) { 19 | return steps.reduce((acc, i) => acc.concat(i.fields), []); 20 | } 21 | 22 | it('can remove a field', () => { 23 | const { result } = getStepsStateShim(defaultSteps); 24 | const { result: fields } = renderHook(() => useFieldConfig(result.current.formSteps.stepEdit)); 25 | 26 | act(() => fields.current.fieldRemove('step-id', { id: 'first-id' })); 27 | 28 | expect(getFields(result.current.state).length).toEqual(1); 29 | }); 30 | 31 | it('can edit a field', () => { 32 | const { result } = getStepsStateShim(defaultSteps); 33 | const { result: fields } = renderHook(() => useFieldConfig(result.current.formSteps.stepEdit)); 34 | 35 | act(() => fields.current.fieldEdit('step-id', { id: 'first-id', title: 'test' })); 36 | 37 | expect(getFields(result.current.state)[0]).toMatchObject({ title: 'test' }); 38 | }); 39 | 40 | it('can add a field', () => { 41 | const { result } = getStepsStateShim(defaultSteps); 42 | const { result: fields } = renderHook(() => useFieldConfig(result.current.formSteps.stepEdit)); 43 | 44 | act(() => fields.current.fieldAdd('step-id')); 45 | 46 | expect(getFields(result.current.state).length).toEqual(3); 47 | }); 48 | 49 | it('can reorder fields', () => { 50 | const { result } = getStepsStateShim(defaultSteps); 51 | const { result: fields } = renderHook(() => useFieldConfig(result.current.formSteps.stepEdit)); 52 | 53 | act(() => fields.current.fieldReorder('step-id', { oldIndex: 0, newIndex: 1 })); 54 | 55 | expect(getFields(result.current.state)[0].id).toEqual('second-id'); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/useFormSteps.test.js: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react-hooks'; 2 | 3 | import { getStepsStateShim } from './test.helpers'; 4 | 5 | describe('useFormSteps', () => { 6 | const defaultSteps = [{ id: '9b33fea9', fields: [] }]; 7 | 8 | it('loads the steps correctly', () => { 9 | const { result } = getStepsStateShim(defaultSteps); 10 | 11 | expect(result.current.state.steps).toMatchObject(defaultSteps); 12 | }); 13 | 14 | it('updates a single step correctly', () => { 15 | const { result } = getStepsStateShim(defaultSteps); 16 | 17 | const firstStep = defaultSteps[0]; 18 | act(() => result.current.formSteps.stepEdit('9b33fea9', { ...firstStep, title: 'Test' })); 19 | 20 | expect(result.current.state.steps[0]).toMatchObject({ ...firstStep, title: 'Test' }); 21 | }); 22 | 23 | it('updates a single step correctly (functional)', () => { 24 | const { result } = getStepsStateShim(defaultSteps); 25 | 26 | const firstStep = defaultSteps[0]; 27 | act(() => result.current.formSteps.stepEdit('9b33fea9', (oldStep) => ({ ...oldStep, title: 'Test' }))); 28 | 29 | expect(result.current.state.steps[0]).toMatchObject({ ...firstStep, title: 'Test' }); 30 | }); 31 | 32 | it('adds a new step', () => { 33 | const { result } = getStepsStateShim(defaultSteps); 34 | 35 | act(() => result.current.formSteps.stepAdd()); 36 | 37 | expect(result.current.state.steps.length).toEqual(2); 38 | }); 39 | 40 | it('removes a step', () => { 41 | const { result } = getStepsStateShim(defaultSteps); 42 | 43 | act(() => result.current.formSteps.stepAdd()); 44 | act(() => result.current.formSteps.stepRemove(result.current.state.steps[1])); 45 | 46 | expect(result.current.state.steps.length).toEqual(1); 47 | }); 48 | 49 | it('can reorder steps', () => { 50 | const { result } = getStepsStateShim(defaultSteps); 51 | 52 | act(() => result.current.formSteps.stepAdd()); 53 | act(() => result.current.formSteps.stepReorder({ oldIndex: 0, newIndex: 1 })); 54 | 55 | expect(result.current.state.steps[0].id).not.toEqual('9b33fea9'); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/useFormSteps.js: -------------------------------------------------------------------------------- 1 | import arrayMove from 'array-move'; 2 | 3 | import { buildStep } from './utils'; 4 | 5 | /** 6 | * Pass in a set of JSON steps and we'll generate a set of functionality 7 | * surrounding editing or reordering those steps 8 | * 9 | * We rely on the parent components state here so this function will use the onChange 10 | * callback to update that. 11 | * 12 | * onChange should be a function which takes a string and a 13 | * onChange('some.maybe.deep.key', someValue) 14 | * */ 15 | export default function useFormSteps(onChange, { steps = [] } = {}) { 16 | const stepsUpdate = (newValues) => { 17 | if (newValues instanceof Function) { 18 | onChange('steps', newValues(steps)); 19 | return; 20 | } 21 | 22 | onChange('steps', newValues); 23 | }; 24 | 25 | const stepAdd = () => 26 | stepsUpdate((oldSteps) => 27 | // Generate a new empty step 28 | oldSteps.concat(buildStep(`New Step ${steps.length + 1}`)) 29 | ); 30 | 31 | const stepRemove = ({ id: idToRemove }) => 32 | // Filter out old step by ID 33 | stepsUpdate((oldSteps) => oldSteps.filter(({ id }) => id !== idToRemove)); 34 | 35 | const stepEdit = (stepId, stepUpdates) => 36 | stepsUpdate((oldSteps) => 37 | oldSteps.map((step) => { 38 | // If matching ID found replace the step, else return old step 39 | // We should pass the entire new step here to update 40 | if (step.id !== stepId) return step; 41 | 42 | // Allow the user to specify their own step updater function 43 | if (stepUpdates instanceof Function) { 44 | return stepUpdates(step); 45 | } 46 | 47 | // User has simply passed us an object to replace the step with 48 | return stepUpdates; 49 | }) 50 | ); 51 | 52 | const stepReorder = ({ oldIndex, newIndex }) => 53 | // Move the item to position requested 54 | stepsUpdate((oldSteps) => arrayMove(oldSteps, oldIndex, newIndex)); 55 | 56 | return { 57 | steps, 58 | stepAdd, 59 | stepEdit, 60 | stepRemove, 61 | stepReorder, 62 | stepsUpdate 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 18 | 19 | 28 | React App 29 | 30 | 31 | 32 |
    33 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoReferenceSearch/CoveoReferenceSearchFieldDisplay.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | // TODO: enable proptypes 3 | import React, { useCallback } from "react"; 4 | import { TextLink } from "@contentful/forma-36-react-components"; 5 | import { MultipleEntryReferenceEditor } from "@contentful/field-editor-reference"; 6 | import "@contentful/forma-36-react-components/dist/styles.css"; 7 | import { get, has, map } from "lodash"; 8 | 9 | import { TYPE_REF_SEARCH } from "../constants"; 10 | 11 | function CoveoReferenceSearchFieldDisplay({ sdk }) { 12 | const { 13 | field, 14 | dialogs: { openExtension: openDialogExtension }, 15 | parameters: { 16 | instance: { searchPageName } 17 | } 18 | } = sdk; 19 | 20 | const addItems = useCallback( 21 | contentIds => { 22 | const items = field.getValue() || []; 23 | const newItems = [ 24 | ...items, 25 | ...map(contentIds, contentId => ({ 26 | sys: { 27 | type: "Link", 28 | linkType: "Entry", 29 | id: contentId 30 | } 31 | })) 32 | ]; 33 | field.setValue(newItems); 34 | }, 35 | [field] 36 | ); 37 | 38 | const openDialog = async () => { 39 | const data = await openDialogExtension({ 40 | width: "fullWidth", 41 | title: "Last Rev Coveo Reference Search", 42 | allowHeightOverflow: true, 43 | parameters: { 44 | searchPageName, 45 | type: TYPE_REF_SEARCH 46 | } 47 | }); 48 | if (has(data, "selectedContentIds")) { 49 | addItems(get(data, "selectedContentIds")); 50 | } 51 | }; 52 | 53 | return ( 54 |
    55 | ( 65 | 66 | Search for content 67 | 68 | )} 69 | /> 70 |
    71 | ); 72 | } 73 | 74 | export default CoveoReferenceSearchFieldDisplay; 75 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/StepList/ConfirmDeleteModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Heading, Button, FormLabel } from '@contentful/forma-36-react-components'; 4 | 5 | import { useSDK } from '../../../context'; 6 | 7 | import { ModalStyle } from '../styles'; 8 | 9 | const Col = styled.div` 10 | display: flex; 11 | flex-direction: column; 12 | `; 13 | 14 | const Row = styled.div` 15 | display: flex; 16 | align-items: center; 17 | flex-direction: row; 18 | `; 19 | 20 | const Tag = styled(FormLabel)` 21 | color: #080808; 22 | min-width: 80px; 23 | background-color: whitesmoke; 24 | 25 | padding: 8px; 26 | margin-right: 8px; 27 | 28 | display: flex; 29 | text-align: right; 30 | align-items: center; 31 | justify-content: flex-end; 32 | `; 33 | 34 | function ConfirmDeleteModal() { 35 | const sdk = useSDK(); 36 | const { type = 'field' } = sdk.parameters.invocation; 37 | 38 | const data = sdk.parameters.invocation[type]; 39 | 40 | const handleCancel = () => sdk.close({ confirmation: null }); 41 | const handleSubmit = () => sdk.close({ confirmation: true }); 42 | 43 | const title = data.title || data.label || data.name; 44 | 45 | return ( 46 | 47 | Confirm delete {type} 48 | {data && ( 49 | 50 | 51 | Id: 52 | {data.id} 53 | 54 | 55 | Label: 56 | 57 | {// Handle object labels 58 | title instanceof Object ? data.name : title} 59 | 60 | 61 | 62 | )} 63 |
    64 |
    65 | 68 | 71 |
    72 |
    73 |
    74 | ); 75 | } 76 | 77 | export default ConfirmDeleteModal; 78 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FieldModal/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { omit } from 'lodash/fp'; 3 | import { Button, Heading } from '@contentful/forma-36-react-components'; 4 | 5 | import { useSDK } from '../../../context'; 6 | 7 | import { ModalStyle } from '../styles'; 8 | 9 | import FieldEditor from './FieldEditor'; 10 | 11 | function isValid(field) { 12 | const { name } = field; 13 | if (name.length < 1) return false; 14 | 15 | const { type } = field; 16 | switch (type) { 17 | case 'select': { 18 | const { options = [] } = field; 19 | return options.length > 0 && options.every(({ label, value }) => label.length > 0 && value.length > 0); 20 | } 21 | 22 | default: 23 | break; 24 | } 25 | 26 | return true; 27 | } 28 | 29 | function FieldModal() { 30 | const sdk = useSDK(); 31 | const { invocation } = sdk.parameters; 32 | const [field, setField] = useState(omit(['modal'], invocation.field || {})); 33 | 34 | const updateField = (maybeKeyMaybeObject, newValue = null) => { 35 | // Allow full object replacement (multiple keys) 36 | if (maybeKeyMaybeObject instanceof Object) { 37 | setField((prev) => ({ 38 | ...prev, 39 | ...maybeKeyMaybeObject 40 | })); 41 | return; 42 | } 43 | 44 | // Allow setting by key & value 45 | setField((prev) => ({ 46 | ...prev, 47 | [maybeKeyMaybeObject]: newValue 48 | })); 49 | }; 50 | 51 | const handleCancel = () => sdk.close({ field: null }); 52 | const handleSubmit = () => sdk.close({ field }); 53 | 54 | return ( 55 | 56 | Field Editor 57 | 58 |
    59 |
    60 | 63 | 71 |
    72 |
    73 |
    74 | ); 75 | } 76 | 77 | export default FieldModal; 78 | -------------------------------------------------------------------------------- /src/__mocks__/mockContentfulAsset.js: -------------------------------------------------------------------------------- 1 | const success = { 2 | "sys": { 3 | "space": { 4 | "sys": { 5 | "type": "Link", 6 | "linkType": "Space", 7 | "id": "9o4l1mrd1tci" 8 | } 9 | }, 10 | "id": "2OjCqPfrMWmUxlCHOT4ovc", 11 | "type": "Asset", 12 | "createdAt": "2019-10-29T17:31:31.862Z", 13 | "updatedAt": "2020-01-24T22:44:10.955Z", 14 | "environment": { 15 | "sys": { 16 | "id": "master", 17 | "type": "Link", 18 | "linkType": "Environment" 19 | } 20 | }, 21 | "publishedVersion": 9, 22 | "publishedAt": "2020-01-24T22:44:10.955Z", 23 | "firstPublishedAt": "2020-01-24T22:40:38.664Z", 24 | "createdBy": { 25 | "sys": { 26 | "type": "Link", 27 | "linkType": "User", 28 | "id": "6Ntte0Bfc9VTOdBwRFarLv" 29 | } 30 | }, 31 | "updatedBy": { 32 | "sys": { 33 | "type": "Link", 34 | "linkType": "User", 35 | "id": "6Ntte0Bfc9VTOdBwRFarLv" 36 | } 37 | }, 38 | "publishedCounter": 2, 39 | "version": 10, 40 | "publishedBy": { 41 | "sys": { 42 | "type": "Link", 43 | "linkType": "User", 44 | "id": "6Ntte0Bfc9VTOdBwRFarLv" 45 | } 46 | } 47 | }, 48 | "fields": { 49 | "title": { 50 | "en-US": "Screen Shot 2020-01-23 at 8.09.12 AM" 51 | }, 52 | "description": { 53 | "en-US": "asdfasdfasdf" 54 | }, 55 | "file": { 56 | "en-US": { 57 | "url": "//images.ctfassets.net/9o4l1mrd1tci/2OjCqPfrMWmUxlCHOT4ovc/e61c74e8219f0b1e8dd0c9d10b4c426b/Screen_Shot_2020-01-23_at_8.09.12_AM.png", 58 | "details": { 59 | "size": 131089, 60 | "image": { 61 | "width": 1936, 62 | "height": 1118 63 | } 64 | }, 65 | "fileName": "Screen Shot 2020-01-23 at 8.09.12 AM.png", 66 | "contentType": "image/png" 67 | } 68 | } 69 | } 70 | }; 71 | 72 | const error = { 73 | "sys": { 74 | "type": "Error", 75 | "id": "NotFound" 76 | }, 77 | "message": "The resource could not be found.", 78 | "details": { 79 | "type": "Asset", 80 | "id": "2OjCqPfrMWmUxlCHOT4ov", 81 | "environment": "master", 82 | "space": "9o4l1mrd1tci" 83 | }, 84 | "requestId": "7d52556546b10f7b1064055a1c8fee18" 85 | }; 86 | 87 | const mockContentfulAsset = { 88 | success, error, 89 | }; 90 | 91 | export default mockContentfulAsset; -------------------------------------------------------------------------------- /src/extensions/ExtensionsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export default function ExtensionsList() { 5 | return ( 6 |
    7 |
      8 |
    • 9 | 10 | Address 11 | 12 |
    • 13 |
    • 14 | 15 | Bynder 16 | 17 |
    • 18 |
    • 19 | 20 | Bynder Image 21 | 22 |
    • 23 |
    • 24 | 25 | Color 26 | 27 |
    • 28 |
    • 29 | 30 | Content Diff 31 | 32 |
    • 33 |
    • 34 | 35 | Coveo Search 36 | 37 |
    • 38 |
    • 39 | 40 | Seo 41 | 42 |
    • 43 |
    • 44 | 45 | Localization Lookup 46 | 47 |
    • 48 |
    • 49 | 50 | Locale Zooms 51 | 52 |
    • 53 |
    • 54 | 55 | Operating Hours 56 | 57 |
    • 58 |
    • 59 | 60 | Person Name 61 | 62 |
    • 63 |
    • 64 | 65 | Phone Number 66 | 67 |
    • 68 |
    • 69 | 70 | Recipe Ingredients 71 | 72 |
    • 73 |
    • 74 | 75 | Recipe Steps 76 | 77 |
    • 78 |
    • 79 | 80 | FormStack 81 | 82 |
    • 83 |
    • 84 | 85 | Form Builder 86 | 87 |
    • 88 |
    89 |
    90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/extensions/BynderImage/BynderImage.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mockBynderData, createMockSDK } from "./mockSdk"; 3 | import BynderImage, { setIfEmpty } from "./BynderImage"; 4 | import { fireEvent } from "@testing-library/dom"; 5 | import { cleanup, render } from "@testing-library/react"; 6 | 7 | afterEach(() => { 8 | cleanup(); 9 | }); 10 | 11 | describe("", () => { 12 | const sdk = createMockSDK(); 13 | const container = render(); 14 | it("should update its internal field when the text field changes", () => { 15 | const textField = container.getByTestId("bynderImageTestId"); 16 | const changeValue = "11223344"; 17 | fireEvent.change(textField, { target: { value: changeValue } }); 18 | expect(sdk.field._value).toEqual(changeValue); 19 | }); 20 | it("should on mounting listen for bynder image changes", () => { 21 | expect(typeof sdk.entry.fields["bynderData"]._callback).toBe("function"); 22 | }); 23 | it("should remove a value if the type field is empty", () => { 24 | const container = render(); 25 | const textField = container.getByTestId("bynderImageTestId"); 26 | const changeValue = null; 27 | fireEvent.change(textField, { target: { value: changeValue } }); 28 | expect(sdk.field._value).toEqual("REMOVED"); 29 | }); 30 | it("should update the fields when the Bynder Image is changed externally", () => { 31 | sdk.entry.fields["bynderData"]._callback(mockBynderData); 32 | expect(sdk.entry.fields["bynderId"]._value).toEqual(mockBynderData[0].id); 33 | expect(sdk.entry.fields["imageName"]._value).toEqual( 34 | mockBynderData[0].name 35 | ); 36 | expect(sdk.entry.fields["altText"]._value).toEqual( 37 | mockBynderData[0].description 38 | ); 39 | expect(sdk.entry.fields["internalTitle"]._value).toEqual( 40 | mockBynderData[0].name 41 | ); 42 | expect(sdk.entry.fields["altTextOverride"]._value).toEqual( 43 | mockBynderData[0].description 44 | ); 45 | }); 46 | }); 47 | 48 | describe("setIfEmpty", () => { 49 | const sdk = createMockSDK(); 50 | it("should only update a field if it doesn't have a value set'", () => { 51 | const fieldId = "internalTitle"; 52 | setIfEmpty(sdk, fieldId, "set this value"); 53 | expect(sdk.entry.fields[fieldId].getValue()).toEqual("set this value"); 54 | setIfEmpty(sdk, fieldId, "don't set this value"); 55 | expect(sdk.entry.fields[fieldId].getValue()).toEqual("set this value"); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/ConfirmDeleteDialog/ConfirmDeleteDialog.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { 4 | Modal, 5 | FieldGroup, 6 | FormLabel, 7 | TextInput, 8 | Form, 9 | Button, 10 | } from "@contentful/forma-36-react-components"; 11 | 12 | const ConfirmDeleteDialogPropTypes = { 13 | item: PropTypes.shape({ 14 | id: PropTypes.string.isRequired, 15 | title: PropTypes.string.isRequired, 16 | }), 17 | onClose: PropTypes.func.isRequired, 18 | onSubmit: PropTypes.func.isRequired, 19 | }; 20 | 21 | const ConfirmDeleteDialog = ({ item, onClose, onSubmit }) => { 22 | const [name, setName] = useState(""); 23 | const [enabled, setEnabled] = useState(false); 24 | 25 | const handleChange = (event) => { 26 | const { value } = event.target; 27 | setName(value); 28 | }; 29 | 30 | useEffect(() => { 31 | if (item && item.title === name) setEnabled(true); 32 | else setEnabled(false); 33 | }, [item, name]); 34 | 35 | useEffect(() => { 36 | if (!item) setName(""); 37 | }, [item]); 38 | 39 | if (!item) return null; 40 | return ( 41 | 42 | To delete {item.title} please type the name 43 |
    44 | 45 | Step Name 46 | 54 | 55 | 56 |
    57 | 65 | 72 |
    73 |
    74 |
    75 |
    76 | ); 77 | }; 78 | 79 | ConfirmDeleteDialog.propTypes = ConfirmDeleteDialogPropTypes; 80 | 81 | ConfirmDeleteDialog.defaultProps = { 82 | item: undefined, 83 | }; 84 | 85 | export default ConfirmDeleteDialog; 86 | -------------------------------------------------------------------------------- /src/extensions/RecipeSteps/dialogs/BulkEditSteps.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | import React, { useState } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import _ from 'lodash'; 5 | import { getTextInput, getTextArea } from '../helpers'; 6 | import { getButton, getBulkEditingTable } from '../../../shared/helpers/formControl'; 7 | 8 | function BulkEditSteps({ sdk }) { 9 | const { rows } = sdk.parameters.invocation; 10 | const [stepsList, setStepsList] = useState(rows); 11 | 12 | const headers = ['step', 'title', 'body']; 13 | 14 | const handleFieldChange = (event) => { 15 | const identifyFieldArray = event.target.id.split('-'); 16 | const updatedArray = stepsList.map((row, index) => { 17 | if (index === parseInt(identifyFieldArray[0], 10)) { 18 | return _.set(row, identifyFieldArray[1], event.target.value); 19 | } 20 | return row; 21 | }); 22 | setStepsList(updatedArray); 23 | }; 24 | 25 | const getTableCell = (row, key, index) => { 26 | const fieldValue = _.get(row, key).toString(); 27 | const fieldId = `${index}-${key}`; 28 | switch (key) { 29 | case 'step': 30 | return getTextInput(fieldValue.toString(), handleFieldChange, { 31 | id: fieldId, 32 | name: fieldId, 33 | type: 'number', 34 | labelText: null, 35 | className: 'bulk-step' 36 | }); 37 | case 'title': 38 | return getTextInput(fieldValue, handleFieldChange, { 39 | id: fieldId, 40 | name: fieldId, 41 | labelText: null 42 | }); 43 | case 'body': 44 | return getTextArea(fieldValue, handleFieldChange, fieldId); 45 | default: 46 | break; 47 | } 48 | }; 49 | 50 | const submitChanges = () => { 51 | sdk.close(stepsList); 52 | }; 53 | 54 | const cancelChanges = () => { 55 | sdk.close(); 56 | }; 57 | 58 | return ( 59 |
    60 | {getBulkEditingTable(headers, stepsList, getTableCell, sdk)} 61 |
    62 | {getButton('Cancel', 'muted', cancelChanges, 'cancel', 'bulk-cancel-btn')} 63 | {getButton('Save Items', 'positive', submitChanges, 'submit', 'bulk-submit-btn')} 64 |
    65 |
    66 | ); 67 | } 68 | 69 | BulkEditSteps.propTypes = { 70 | sdk: PropTypes.shape({ 71 | parameters: PropTypes.shape({ 72 | invocation: PropTypes.shape({ 73 | rows: PropTypes.array 74 | }) 75 | }), 76 | close: PropTypes.func 77 | }).isRequired 78 | }; 79 | 80 | export default BulkEditSteps; 81 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoSearchService.js: -------------------------------------------------------------------------------- 1 | import { each } from "lodash"; 2 | 3 | /* global Coveo */ 4 | class CoveoSearchService { 5 | static async getInstance({ sdk, listeners }) { 6 | if (!CoveoSearchService._instance) { 7 | CoveoSearchService._instance = new CoveoSearchService({ sdk }); 8 | await CoveoSearchService._instance.init(listeners); 9 | } 10 | return CoveoSearchService._instance; 11 | } 12 | 13 | constructor({ sdk }) { 14 | this.endpoint = sdk.parameters.installation.endpoint; 15 | } 16 | 17 | async init() { 18 | let counter = 0; 19 | while (counter < 5 && typeof Coveo === "undefined") { 20 | // eslint-disable-next-line no-await-in-loop 21 | await new Promise(resolve => setTimeout(resolve, 1000)); 22 | counter += 1; 23 | } 24 | 25 | if (typeof Coveo === "undefined") { 26 | throw Error("Unable to initialize Coveo library!"); 27 | } 28 | 29 | const response = await fetch(this.endpoint, { 30 | method: "POST", 31 | body: JSON.stringify({ 32 | action: "GET_API_DATA" 33 | }) 34 | }); 35 | 36 | const { 37 | data: { apiKey, org } 38 | } = await response.json(); 39 | 40 | await Coveo.SearchEndpoint.configureCloudV2Endpoint(org, apiKey); 41 | } 42 | 43 | // eslint-disable-next-line class-methods-use-this 44 | async initCoveo({ searchContainer, listeners = [], existingState }) { 45 | let counter = 0; 46 | while (counter < 5 && typeof Coveo === "undefined") { 47 | // eslint-disable-next-line no-await-in-loop 48 | await new Promise(resolve => setTimeout(resolve, 1000)); 49 | counter += 1; 50 | } 51 | 52 | if (typeof Coveo === "undefined") { 53 | throw Error("Unable to initialize Coveo library!"); 54 | } 55 | 56 | Coveo.init(searchContainer); 57 | 58 | const root = document.querySelector("#search"); 59 | 60 | if (existingState) { 61 | Coveo.state(root, existingState); 62 | Coveo.executeQuery(root); 63 | } 64 | 65 | each(listeners, (handle, key) => { 66 | Coveo.$$(root).on(key, (_e, args) => { 67 | handle(args, Coveo.state(root).attributes); 68 | }); 69 | }); 70 | } 71 | 72 | // eslint-disable-next-line class-methods-use-this 73 | initSearchbox(searchBox, searchPageUri, options = {}) { 74 | Coveo.initSearchbox(searchBox, searchPageUri, options); 75 | } 76 | } 77 | 78 | // TODO: Not sure why static class members aren't accepted 79 | // So had to write it in this format ?!? 80 | CoveoSearchService._instance = null; 81 | 82 | export default CoveoSearchService; 83 | -------------------------------------------------------------------------------- /src/extensions/Seo/mockSdk.js: -------------------------------------------------------------------------------- 1 | const mockSeoJson = { 2 | 'title': { name: 'title', value: 'Last Rev: Connecting the Modern Web' }, 3 | 'robots': { name: 'robots', value: 'index,follow' }, 4 | 'description': { 5 | name: 'description', 6 | value: 7 | 'Me non paenitet nullum festiviorem excogitasse ad hoc. Unam incolunt Belgae, aliam Aquitani, tertiam. Inmensae subtilitatis, obscuris et malesuada fames.' 8 | }, 9 | 'keywords': { name: 'keywords', value: 'These, are my, keywords' }, 10 | 'canonical': { name: 'canonical', value: 'https://www.lastrev.com' }, 11 | 'og:title': { name: 'og:title', value: 'Social Sharing Title' }, 12 | 'og:description': { name: 'og:description', value: 'Social Sharing Description' }, 13 | 14 | 'twitter:image': { 15 | name: 'twitter:image', 16 | value: 17 | '{"sys":{"space":{"sys":{"type":"Link","linkType":"Space","id":"9o4l1mrd1tci"}},"id":"5VwseUvM96DL4TCKH42IM6","type":"Asset","createdAt":"2019-08-30T23:33:29.011Z","updatedAt":"2019-08-30T23:33:40.228Z","environment":{"sys":{"id":"master","type":"Link","linkType":"Environment"}},"publishedVersion":4,"publishedAt":"2019-08-30T23:33:40.228Z","firstPublishedAt":"2019-08-30T23:33:40.228Z","createdBy":{"sys":{"type":"Link","linkType":"User","id":"6Ntte0Bfc9VTOdBwRFarLv"}},"updatedBy":{"sys":{"type":"Link","linkType":"User","id":"6Ntte0Bfc9VTOdBwRFarLv"}},"publishedCounter":1,"version":5,"publishedBy":{"sys":{"type":"Link","linkType":"User","id":"6Ntte0Bfc9VTOdBwRFarLv"}}},"fields":{"title":{"en-US":"Screen Shot 2019-08-30 at 1.10.13 PM"},"file":{"en-US":{"url":"//images.ctfassets.net/9o4l1mrd1tci/5VwseUvM96DL4TCKH42IM6/d05b9b4773e44de340dc50051c8b5bf2/Screen_Shot_2019-08-30_at_1.10.13_PM.png","details":{"size":183321,"image":{"width":1948,"height":742}},"fileName":"Screen Shot 2019-08-30 at 1.10.13 PM.png","contentType":"image/png"}}}}' 18 | } 19 | }; 20 | const mockSdk = { 21 | field: { 22 | getValue: () => { 23 | return mockSeoJson; 24 | }, 25 | setValue: () => { 26 | return null; 27 | }, 28 | locale: 'en-US' 29 | }, 30 | dialogs: { 31 | selectSingleAsset: (options) => 32 | new Promise((resolve, reject) => { 33 | resolve({ 34 | sys: { 35 | id: '1213456' 36 | }, 37 | fields: { 38 | file: { 39 | 'en-US': { 40 | url: '//placehold.it/600x315' 41 | } 42 | }, 43 | title: { 44 | 'en-US': 'The is the placeholder image' 45 | } 46 | } 47 | }); 48 | }) 49 | } 50 | }; 51 | 52 | export default mockSdk; 53 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FieldModal/SchemaEditor/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { CheckboxField, FieldGroup, SelectField, Option } from '@contentful/forma-36-react-components'; 5 | 6 | import { schemaPropType } from './prop-types'; 7 | import AdditionalOptions from './AdditionalOptions'; 8 | 9 | const SchemaWrapper = styled(FieldGroup)` 10 | margin-top: 1rem; 11 | margin-bottom: 1rem; 12 | `; 13 | 14 | const FastestValidatorTypes = [ 15 | // prettier-no-wrap 16 | { label: 'Any', value: 'any' }, 17 | { label: 'Boolean', value: 'boolean' }, 18 | { label: 'Email', value: 'email' }, 19 | { label: 'Equals value', value: 'equal' }, 20 | { label: 'Number', value: 'number' }, 21 | { label: 'Object', value: 'object' }, 22 | { label: 'String', value: 'string' }, 23 | { label: 'Url', value: 'url' }, 24 | { label: 'UUID', value: 'uuid' } 25 | ]; 26 | 27 | // eslint-disable-next-line no-unused-vars 28 | function validatorTypesForField({ type }) { 29 | // TODO: Which types for which fields 30 | // think about NPM module 31 | return FastestValidatorTypes; 32 | } 33 | 34 | function SchemaEditor({ field, updateField }) { 35 | const { schema = {} } = field; 36 | 37 | return ( 38 | 39 | 45 | updateField('schema', { 46 | type: event.currentTarget.value 47 | }) 48 | }> 49 | {validatorTypesForField(field).map(({ label, value }) => ( 50 | 53 | ))} 54 | 55 | 61 | updateField('schema', { 62 | ...schema, 63 | required: event.target.checked 64 | }) 65 | } 66 | /> 67 | 68 | 69 | ); 70 | } 71 | 72 | SchemaEditor.propTypes = { 73 | updateField: PropTypes.func.isRequired, 74 | field: PropTypes.shape({ 75 | schema: schemaPropType 76 | }) 77 | }; 78 | 79 | SchemaEditor.defaultProps = { 80 | field: {} 81 | }; 82 | 83 | export default SchemaEditor; 84 | -------------------------------------------------------------------------------- /src/extensions/OperatingHours/OverrideDaysTable/OverrideDaysTableRow.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | CardActions, 5 | DropdownList, 6 | DropdownListItem, 7 | Switch, 8 | TableRow, 9 | TableCell, 10 | TextInput, 11 | } from '@contentful/forma-36-react-components'; 12 | import { format } from 'date-fns'; 13 | import TimezoneDropdown from '../../../shared/components/TimezoneDropdown'; 14 | 15 | function OverrideDaysTableRow({ id, position, value, clickEdit, clickRemove }) { 16 | return ( 17 | 18 | 19 | 24 | 25 | 26 | {}} 29 | disabled 30 | position={position} 31 | className="operatingHours__timezone" 32 | /> 33 | 34 | 35 | 42 | 43 | 44 | { value.timeRange.join(' - ') } 45 | 46 | 47 | 48 | 49 | 52 | Edit 53 | 54 | 57 | Remove 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | } 65 | 66 | OverrideDaysTableRow.propTypes = { 67 | id: PropTypes.string.isRequired, 68 | position: PropTypes.string.isRequired, 69 | value: PropTypes.shape({ 70 | date: PropTypes.string, 71 | isClosed: PropTypes.bool, 72 | timezone: PropTypes.string, 73 | timeRange: PropTypes.arrayOf(PropTypes.string) 74 | }).isRequired, 75 | clickEdit: PropTypes.func.isRequired, 76 | clickRemove: PropTypes.func.isRequired, 77 | }; 78 | 79 | export default OverrideDaysTableRow; 80 | -------------------------------------------------------------------------------- /src/extensions/PersonName/PersonName.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { TextField } from "@contentful/forma-36-react-components"; 3 | import PropTypes from "prop-types"; 4 | 5 | export default function PersonName({ sdk }) { 6 | const [fieldValue, setFieldValue] = useState({}); 7 | 8 | useEffect(() => { 9 | if (sdk.field.getValue()) { 10 | setFieldValue(sdk.field.getValue()); 11 | } else { 12 | setFieldValue({}); 13 | }; 14 | 15 | }, [sdk.field]); 16 | 17 | const handleFieldChange = (fieldName) => (e) => { 18 | fieldValue[fieldName] = e.currentTarget.value; 19 | sdk.field.setValue(fieldValue); 20 | setFieldValue(fieldValue); 21 | }; 22 | 23 | return ( 24 |
    25 | 34 | 44 | 52 | 61 | 69 | 77 |
    78 | ); 79 | } 80 | 81 | PersonName.propTypes = { 82 | sdk: PropTypes.shape({ 83 | field: PropTypes.shape({ 84 | getValue: PropTypes.func.isRequired, 85 | setValue: PropTypes.func.isRequired, 86 | }), 87 | }).isRequired, 88 | }; 89 | -------------------------------------------------------------------------------- /src/extensions/CoveoSearch/CoveoSavedSearch/CoveoSavedSearchFieldDisplay.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | // TODO: enable proptypes 3 | import React, { useEffect, useState } from 'react'; 4 | import { Button } from '@contentful/forma-36-react-components'; 5 | import '@contentful/forma-36-react-components/dist/styles.css'; 6 | import { get, has, isArray, pickBy } from 'lodash'; 7 | import { TYPE_SAVED_SEARCH } from '../constants'; 8 | 9 | function CoveoSavedSearchFieldDisplay({ sdk }) { 10 | const { 11 | field, 12 | dialogs: { openExtension: openDialogExtension }, 13 | parameters: { 14 | instance: { searchPageName } 15 | } 16 | } = sdk; 17 | 18 | const [fieldValue, setFieldValue] = useState(null); 19 | 20 | useEffect(() => { 21 | const detachValueChangeHandler = field.onValueChanged(setFieldValue); 22 | setFieldValue(field.getValue()); 23 | 24 | return () => { 25 | detachValueChangeHandler(setFieldValue); 26 | }; 27 | }, [field]); 28 | 29 | const openDialog = async () => { 30 | const data = await openDialogExtension({ 31 | width: 'fullWidth', 32 | title: 'Last Rev Coveo Saved Search', 33 | allowHeightOverflow: true, 34 | parameters: { 35 | type: TYPE_SAVED_SEARCH, 36 | searchPageName, 37 | query: get(fieldValue, 'query'), 38 | state: get(fieldValue, 'state') 39 | } 40 | }); 41 | if (has(data, 'state') && has(data, 'query')) { 42 | const pruned = pickBy(get(data, 'state'), (val) => { 43 | if (!val) return false; 44 | if (isArray(val) && !val.length) return false; 45 | return true; 46 | }); 47 | 48 | const numberOfItems = get(data, 'numberOfItems'); 49 | field.setValue({ query: { ...get(data, 'query'), numberOfResults: numberOfItems }, state: pruned }); 50 | } 51 | }; 52 | 53 | const removeSavedSearch = () => { 54 | field.setValue(null); 55 | }; 56 | 57 | return ( 58 | <> 59 | {fieldValue ? ( 60 | <> 61 |
    Saved search parameters:
    62 |
    {JSON.stringify(fieldValue, null, 2)}
    63 | 64 | ) : ( 65 |
    No search saved.
    66 | )} 67 | 68 | 71 | {fieldValue ? ( 72 | 75 | ) : null} 76 | 77 | ); 78 | } 79 | 80 | export default CoveoSavedSearchFieldDisplay; 81 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/useProviderConfig.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { merge } from 'lodash/fp'; 3 | 4 | import { URL_TYPES } from './utils'; 5 | 6 | /** 7 | * Provider configuration holds the remote endpoint for the form 8 | * currently this is mostly hubspot, but in future the idea is to add 9 | * different provider configurations here. 10 | * 11 | * By passing in the initial provider configuration we get a set of functions 12 | * which can be used to update that (and callback to contentful) 13 | 14 | * * onChange should be a function which takes a string and a 15 | * onChange('some.maybe.deep.key', someValue) 16 | * */ 17 | export default function useProviderConfig(onChangeField, { provider = {} } = {}) { 18 | const { parameters = {}, type = 'custom' } = provider; 19 | const { formId = '', portalId = '', url = '', method = 'POST' } = parameters; 20 | 21 | const [values, setValues] = useState({ type, formId, portalId, url, method }); 22 | 23 | return { 24 | ...values, 25 | 26 | setType: (newType) => { 27 | // Save to contentful 28 | onChangeField('provider.type', newType); 29 | 30 | setValues((oldValues) => 31 | merge(oldValues)({ 32 | type: newType, 33 | 34 | // Disable the URL if this type does not support it 35 | formId: URL_TYPES.includes(newType) ? oldValues.formId : null, 36 | portalId: URL_TYPES.includes(newType) ? oldValues.portalId : null 37 | }) 38 | ); 39 | }, 40 | 41 | setFormId: (newUrl) => { 42 | // Save to contentful 43 | onChangeField('provider.parameters.formId', newUrl); 44 | 45 | setValues((oldValues) => merge(oldValues)({ formId: newUrl })); 46 | }, 47 | 48 | setPortalId: (newUrl) => { 49 | // Save to contentful 50 | onChangeField('provider.parameters.portalId', newUrl); 51 | 52 | setValues((oldValues) => merge(oldValues)({ portalId: newUrl })); 53 | }, 54 | 55 | setUrl: (newUrl) => { 56 | // Save to contentful 57 | onChangeField('provider.parameters.url', newUrl); 58 | 59 | setValues((oldValues) => merge(oldValues)({ url: newUrl })); 60 | }, 61 | 62 | setMethod: (newMethod) => { 63 | // Save to contentful 64 | onChangeField('provider.parameters.method', newMethod); 65 | 66 | setValues((oldValues) => 67 | merge(oldValues)({ 68 | method: newMethod 69 | }) 70 | ); 71 | }, 72 | 73 | update: ({ parameters: newParameters, type: newType }) => 74 | setValues({ 75 | ...values, 76 | ...newParameters, 77 | ...(newType && { type: newType }) 78 | }) 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@contentful/field-editor-reference": "^1.11.0", 7 | "@contentful/forma-36-fcss": "0.0.34", 8 | "@contentful/forma-36-react-components": "^3.42.2", 9 | "@contentful/rich-text-html-renderer": "^14.1.1", 10 | "@contentful/rich-text-types": "^14.1.1", 11 | "@material-ui/core": "^4.11.0", 12 | "@sentry/react": "^5.20.0", 13 | "@testing-library/dom": "^7.20.0", 14 | "@testing-library/jest-dom": "^4.2.4", 15 | "@testing-library/react": "^9.5.0", 16 | "@testing-library/user-event": "^7.1.2", 17 | "@types/jest": "^25.2.3", 18 | "array-move": "^3.0.1", 19 | "axios": "^0.19.2", 20 | "contentful": "^7.14.5", 21 | "contentful-ui-extensions-sdk": "^3.16.1", 22 | "copy-to-clipboard": "^3.3.1", 23 | "date-fns": "^2.16.1", 24 | "faker": "^5.3.1", 25 | "history": "^4.10.1", 26 | "json-logic-js": "^2.0.0", 27 | "lodash": "^4.17.19", 28 | "node-htmldiff": "^0.9.3", 29 | "node-sass": "^4.14.1", 30 | "prop-types": "^15.7.2", 31 | "react": "^16.13.1", 32 | "react-datepicker": "^3.2.2", 33 | "react-dom": "^16.13.1", 34 | "react-router-dom": "^5.2.0", 35 | "react-scripts": "3.3.0", 36 | "react-select": "^4.0.2", 37 | "react-sortable-hoc": "^1.11.0", 38 | "styled-components": "^5.2.1", 39 | "uuid": "^8.3.0" 40 | }, 41 | "scripts": { 42 | "start": "react-scripts start", 43 | "build": "react-scripts build", 44 | "test": "react-scripts test --env=jest-environment-jsdom-sixteen", 45 | "testfull": "react-scripts test --verbose", 46 | "eject": "react-scripts eject", 47 | "lint": "eslint . --fix" 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.2%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | }, 61 | "devDependencies": { 62 | "@testing-library/react-hooks": "^4.0.0", 63 | "eslint-config-airbnb": "^18.2.0", 64 | "eslint-config-prettier": "^6.11.0", 65 | "eslint-plugin-jsx-a11y": "^6.3.1", 66 | "eslint-plugin-prettier": "^3.1.4", 67 | "husky": "^4.2.5", 68 | "jest-environment-jsdom-sixteen": "^1.0.3", 69 | "lint-staged": "^9.5.0", 70 | "prettier": "^1.19.1", 71 | "react-test-renderer": "^16.13.1" 72 | }, 73 | "husky": { 74 | "hooks": { 75 | "pre-commit": "lint-staged" 76 | } 77 | }, 78 | "lint-staged": { 79 | "src/**/*.{js,jsx,json,css}": [ 80 | "prettier --write", 81 | "eslint --fix", 82 | "git add" 83 | ] 84 | }, 85 | "volta": { 86 | "node": "10.16.3" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/extensions/RecipeSteps/dialogs/StepDialog.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Form, FieldGroup } from '@contentful/forma-36-react-components'; 4 | import { getTextAreaWithLabel } from '../helpers'; 5 | import { getButton, getTextField } from '../../../shared/helpers'; 6 | 7 | const StepDialog = ({ sdk }) => { 8 | const [step, setStep] = useState(''); 9 | const [title, setTitle] = useState(''); 10 | const [body, setBody] = useState(''); 11 | const [stepErrorMessage, setStepErrorMessage] = useState(''); 12 | const [titleErrorMessage, setTitleErrorMessage] = useState(''); 13 | 14 | useEffect(() => { 15 | if (sdk.parameters.invocation.step) { 16 | setStep(sdk.parameters.invocation.step.step); 17 | setTitle(sdk.parameters.invocation.step.title); 18 | setBody(sdk.parameters.invocation.step.body); 19 | } 20 | }, [sdk]); 21 | 22 | const closeDialog = () => { 23 | sdk.close(); 24 | }; 25 | 26 | const saveStep = () => { 27 | const errorMessage = 'This item is required'; 28 | if (step && title) { 29 | sdk.close({ step: +step, title, body }); 30 | } else { 31 | setStepErrorMessage(!step ? errorMessage : ''); 32 | setTitleErrorMessage(!title ? errorMessage : ''); 33 | } 34 | }; 35 | 36 | return ( 37 |
    38 |
    39 | 40 | {getTextField(step, (event) => setStep(event.currentTarget.value), stepErrorMessage, { 41 | id: 'stepNumber', 42 | type: 'number', 43 | labelText: 'Step Number', 44 | required: true 45 | })} 46 | 47 | 48 | {getTextField(title, (event) => setTitle(event.currentTarget.value), titleErrorMessage, { 49 | id: 'title', 50 | labelText: 'Title', 51 | required: true 52 | })} 53 | 54 | {getTextAreaWithLabel(body, 'Body', (event) => setBody(event.currentTarget.value))} 55 | 56 | {getButton('Save', 'positive', saveStep)} 57 | {getButton('Cancel', 'muted', closeDialog)} 58 | 59 |
    60 |
    61 | ); 62 | }; 63 | 64 | StepDialog.propTypes = { 65 | sdk: PropTypes.shape({ 66 | close: PropTypes.func.isRequired, 67 | parameters: PropTypes.shape({ 68 | invocation: PropTypes.shape({ 69 | step: PropTypes.shape({ 70 | step: PropTypes.string, 71 | title: PropTypes.string, 72 | body: PropTypes.string 73 | }) 74 | }).isRequired 75 | }).isRequired 76 | }).isRequired 77 | }; 78 | 79 | export default StepDialog; 80 | -------------------------------------------------------------------------------- /src/extensions/ContentDiff/helpers/lookups.js: -------------------------------------------------------------------------------- 1 | import { firstIndex } from '../constants'; 2 | 3 | let entryLookup = {}; 4 | let assetLookup = {}; 5 | let snapshotsLookup = {}; 6 | let controlsLookup = {}; 7 | let contentTypeLookup = {}; 8 | 9 | const addProperty = (json, name, value) => { 10 | return !json[name] 11 | ? { 12 | ...json, 13 | [name]: value, 14 | } 15 | : json; 16 | }; 17 | 18 | const getLookupItem = async (lookupItem, getItem) => { 19 | let item = lookupItem; 20 | if (!item) { 21 | item = await getItem(); 22 | } 23 | return item; 24 | }; 25 | 26 | export const getContentType = async (id, space) => { 27 | const contentType = await getLookupItem(contentTypeLookup[id], () => space.getContentType(id)); 28 | contentTypeLookup = addProperty(contentTypeLookup, id, contentType); 29 | return contentType; 30 | }; 31 | 32 | export const getEntry = async (id, space) => { 33 | const entry = await getLookupItem(entryLookup[id], () => space.getEntry(id)); 34 | entryLookup = addProperty(entryLookup, id, entry); 35 | return entry; 36 | }; 37 | 38 | export const addEntry = (id, entry) => { 39 | addProperty(entryLookup, id, entry); 40 | }; 41 | 42 | export const getAsset = async (id, space) => { 43 | const asset = await getLookupItem(assetLookup[id], () => space.getAsset(id)); 44 | assetLookup = addProperty(assetLookup, id, asset); 45 | return asset; 46 | }; 47 | 48 | export const getEntrySnapshots = async (id, space) => { 49 | const snapshots = await getLookupItem(snapshotsLookup[id], () => space.getEntrySnapshots(id)); 50 | snapshotsLookup = addProperty(snapshotsLookup, id, snapshots); 51 | return snapshots; 52 | }; 53 | 54 | export const addEntrySnapshots = async (id, snapshots) => { 55 | addProperty(snapshotsLookup, id, snapshots); 56 | }; 57 | 58 | export const getEditorInterface = async (id, space) => { 59 | const editorInterface = await getLookupItem(controlsLookup[id], () => space.getEditorInterface(id)); 60 | controlsLookup = addProperty(controlsLookup, id, editorInterface); 61 | return editorInterface; 62 | }; 63 | 64 | export const addEditorInterface = (id, controls) => { 65 | addProperty(controlsLookup, id, controls); 66 | }; 67 | 68 | export const resetLookups = () => { 69 | entryLookup = {}; 70 | assetLookup = {}; 71 | snapshotsLookup = {}; 72 | controlsLookup = {}; 73 | contentTypeLookup = {}; 74 | }; 75 | 76 | export const getEntryByDate = async (space, entryId, snapshotDate) => { 77 | let entry; 78 | if (snapshotDate) { 79 | const snapshots = await getEntrySnapshots(entryId, space); 80 | entry = 81 | snapshots && 82 | snapshots.items && 83 | snapshots.items.filter((item) => new Date(item.sys.updatedAt) <= snapshotDate)[firstIndex]; 84 | } else { 85 | entry = await getEntry(entryId, space); 86 | } 87 | return entry; 88 | }; 89 | -------------------------------------------------------------------------------- /src/extensions/PhoneNumber/PhoneNumber.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, configure, cleanup, fireEvent } from '@testing-library/react'; 3 | import PhoneNumber from './PhoneNumber'; 4 | 5 | let sdk = null; 6 | 7 | configure({ 8 | testIdAttribute: 'data-test-id', 9 | }); 10 | 11 | beforeEach(() => { 12 | sdk = { 13 | field: { 14 | getValue: () => ({}), 15 | setValue: jest.fn(val => val), 16 | }, 17 | }; 18 | }); 19 | 20 | afterEach(() => { 21 | cleanup(); 22 | }); 23 | 24 | describe('', () => { 25 | describe('when SDK field updates externally the fields are updated', () => { 26 | test('Initial SDK field value is set on inputs', async () => { 27 | const value = { 28 | label: 'Phone Label', 29 | phoneNumber: '1234567890', 30 | extension: '1234', 31 | }; 32 | 33 | sdk.field.getValue = jest.fn(() => value); 34 | 35 | const { getByLabelText } = render(); 36 | 37 | expect(getByLabelText('Label').value).toBe(value.label); 38 | expect(getByLabelText('Phone Number', { exact: false }).value).toBe(value.phoneNumber); 39 | expect(getByLabelText('Extension').value).toBe(value.extension); 40 | }); 41 | }); 42 | 43 | describe('change events update the SDK field', () => { 44 | test('SDK set value is called when updating label field', () => { 45 | const { getByLabelText } = render(); 46 | 47 | const element = getByLabelText('Label'); 48 | 49 | const TEST_VALUE = 'Phone Test Label'; 50 | 51 | fireEvent.change(element, { 52 | target: { 53 | value: TEST_VALUE 54 | } 55 | }); 56 | 57 | expect(sdk.field.setValue).toHaveBeenCalledTimes(1); 58 | expect(element.value).toBe(TEST_VALUE); 59 | }); 60 | 61 | test('SDK set value is called when updating phone number field', () => { 62 | const { getByLabelText } = render(); 63 | 64 | const element = getByLabelText('Phone Number', { exact: false }); 65 | 66 | const TEST_VALUE = '5555555555'; 67 | 68 | fireEvent.change(element, { 69 | target: { 70 | value: TEST_VALUE 71 | } 72 | }); 73 | 74 | expect(sdk.field.setValue).toHaveBeenCalledTimes(1); 75 | expect(element.value).toBe(TEST_VALUE); 76 | }); 77 | 78 | test('SDK set value is called when updating extension field', () => { 79 | const { getByLabelText } = render(); 80 | 81 | const element = getByLabelText('Extension'); 82 | 83 | const TEST_VALUE = '123'; 84 | 85 | fireEvent.change(element, { 86 | target: { 87 | value: TEST_VALUE 88 | } 89 | }); 90 | 91 | expect(sdk.field.setValue).toHaveBeenCalledTimes(1); 92 | expect(element.value).toBe(TEST_VALUE); 93 | }); 94 | }); 95 | }); -------------------------------------------------------------------------------- /src/extensions/LocalizationLookup/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: LocalizationLookup 2 | 3 | The Last Rev LocalizationLookup extension can be used to create a simple JSON object. You can add, edit, and/or delete as many JSON fields as you would like. 4 | 5 | ## Setup Instructions 6 | 7 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 8 | 2. Create a new UI Extension in your space and choose these following Options: 9 | - Name: LocalizationLookup 10 | - Field Types: Object 11 | - Hosting: Self-hosted(src) 12 | - Self-Hosted URL: [https://your-extension-domain.netlify.com/localization-lookup](https://your-extension-domain.netlify.com/localization-lookup) 13 | 3. Create an Object (JSON) field in your content model where you want to use the LocalizationLookup field 14 | 4. On the Content Model page, select "Settings" on the new JSON field you added 15 | 5. Go to Appearance and select your new UI Extension 16 | 17 | ## Output Example 18 | 19 | ```json 20 | { 21 | "headerRegistrationSuccessful": "Registration Successful Header", 22 | "headerRegisteredLiveCourses": "Registered Live Courses", 23 | "headerEnterRegistrationInfo": "Enter Registration Info", 24 | "headerAccountInfo": "Account Info", 25 | "headerEventDescription": "Event Description", 26 | "headerLiveCourseHighlightsHeader": "This virtual instructor-led class will help you:", 27 | "headerDuration": "Duration", 28 | "headerPersonalInfo": "Personal Info", 29 | "headerSuggestedTopics": "Suggested Topics", 30 | "breadcrumbTextRegistration": "Registration", 31 | "formInputLabelPickLanguage": "Pick your language", 32 | "formInputLabelSelectTime": "Select a Time", 33 | "formInputLabelSelectDate": "Select a Date", 34 | "formInputLabelFirstName": "First Name", 35 | "formInputLabelLastName": "Last Name", 36 | "formInputLabelEmail": "Email", 37 | "accountLabelFirst": "First", 38 | "accountLabelLast": "Last", 39 | "accountLabelEmail": "Email", 40 | "formInputLabelAttendee": "Attendee", 41 | "formInputLabelSelectDateBreadcrumb": "Select Date Breadcrumb", 42 | "formInputLabelCourse": "Course", 43 | "formInputLabelDate": "Date", 44 | "formInputLabelSessionLink": "Session Link", 45 | "formInputLabelGeneralRegistration": "General Registration", 46 | "ctaTextCompleteRegistration": "Complete Registration", 47 | "ctaTextReschedule": "Reschedule", 48 | "ctaTextCancelClass": "Cancel Class", 49 | "ctaTextRegister": "Register", 50 | "ctaTextSignIn": "Sign In", 51 | "ctaTextProfile": "Profile", 52 | "ctaTextLogout": "Logout", 53 | "ctaTextBack": "Back", 54 | "globalTextCopyright": "Copyright Text", 55 | "textMinutes": "minutes" 56 | } 57 | ``` 58 | 59 | ## Reporting Issues 60 | 61 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! 62 | -------------------------------------------------------------------------------- /src/extensions/Seo/README.md: -------------------------------------------------------------------------------- 1 | # Last Rev: SEO 2 | 3 | The Last Rev SEO extension can be used to enable content creators to add meta data to websites for page titles, descriptions, and social sites like Facebook and Twitter 4 | 5 | ## Setup Instructions 6 | 7 | 1. [Click here to deploy](https://app.netlify.com/start/deploy?repository=https://github.com/last-rev-llc/contentful-ui-extensions) to Netlify or deploy this repo to a hosting provider of your choice. 8 | 2. Create a new UI Extension in your space and choose thos following Options: 9 | - Name: SEO 10 | - Field Types: Object 11 | - Hosting: Self-hosted(src) 12 | - Self-Hosted URL: [https://your-extension-domain.netlify.com/seo](https://your-extension-domain.netlify.com/seo) 13 | 3. Create an Object (JSON) field in your content model you want to use the SEO field 14 | 4. On the Content Model page, select "Settings" on the new JSON field you added 15 | 5. Go to Appearance and select your new UI Extension 16 | 17 | ## Output Example 18 | 19 | ```json 20 | { 21 | "title": { 22 | "name": "title", 23 | "value": "Last Rev | Connecting the modern web" 24 | }, 25 | "robots": { 26 | "name": "robots", 27 | "value": "index,follow" 28 | }, 29 | "keywords": { 30 | "name": "keywords", 31 | "value": "contentful, ui extensions, react, seo" 32 | }, 33 | "og:image": { 34 | "name": "og:image", 35 | "value": { 36 | "id": "5VwseUvM96DL4TCKH42IM6", 37 | "url": "https://images.ctfassets.net/9o4l1mrd1tci/5VwseUvM96DL4TCKH42IM6/d05b9b4773e44de340dc50051c8b5bf2/Screen_Shot_2019-08-30_at_1.10.13_PM.png", 38 | "title": "My Facebook Image" 39 | } 40 | }, 41 | "og:title": { 42 | "name": "og:title", 43 | "value": "Last Rev | My facebook title is different" 44 | }, 45 | "description": { 46 | "name": "description", 47 | "value": "Morbi fringilla convallis sapien, id pulvinar odio volutpat. Nec dubitamus multa iter quae et nos invenerat. At nos hinc posthac, sitientis piros Afros." 48 | }, 49 | "twitter:image": { 50 | "name": "twitter:image", 51 | "value": { 52 | "id": "2OjCqPfrMWmUxlCHOT4ovc", 53 | "url": "https://images.ctfassets.net/9o4l1mrd1tci/2OjCqPfrMWmUxlCHOT4ovc/e61c74e8219f0b1e8dd0c9d10b4c426b/Screen_Shot_2020-01-23_at_8.09.12_AM.png", 54 | "title": "Screen Shot 2020-01-23 at 8.09.12 AM" 55 | } 56 | }, 57 | "og:description": { 58 | "name": "og:description", 59 | "value": "Morbi fringilla convallis sapien, id pulvinar odio volutpat. Nec dubitamus multa iter quae et nos invenerat. At nos hinc posthac, sitientis piros Afros." 60 | }, 61 | "twitter:description": { 62 | "name": "twitter:description", 63 | "value": "Morbi fringilla convallis sapien, id pulvinar odio volutpat. Nec dubitamus multa iter quae et nos invenerat. At nos hinc posthac, sitientis piros Afros." 64 | } 65 | } 66 | ``` 67 | 68 | ## Reporting Issues 69 | 70 | If you find any bugs or want to suggest a feature, please submit them on the Github repo Issues tab. Thanks! 71 | -------------------------------------------------------------------------------- /src/extensions/OperatingHours/DaysOfWeekTable/DaysOfWeekTable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | Switch, 5 | Table, 6 | TableHead, 7 | TableBody, 8 | TableRow, 9 | TableCell, 10 | } from '@contentful/forma-36-react-components'; 11 | import TimeRange from '../../../shared/components/TimeRange'; 12 | import TimezoneDropdown from '../../../shared/components/TimezoneDropdown'; 13 | 14 | function DaysOfWeekTable({ daysOfWeek, onChange }) { 15 | return ( 16 | <> 17 | 18 | 19 | 20 | Day of Week 21 | Timezone 22 | Closed? 23 | Open/Close Times 24 | 25 | 26 | 27 | { 28 | daysOfWeek.map((dayOfWeek, index) => ( 29 | 30 | { dayOfWeek.dayOfWeek } 31 | 32 | onChange(index, { timezone: e.currentTarget.value })} 36 | position={`line-${index}`} 37 | className="operatingHours__timezone" /> 38 | 39 | 42 | onChange(index, { isClosed: isChecked })} /> 47 | 48 | 50 | onChange(index, { timeRange: value })} 54 | step={{ minutes: 30 }} 55 | disabled={dayOfWeek.isClosed} /> 56 | 57 | 58 | )) 59 | } 60 | 61 |
    62 | 63 | ); 64 | } 65 | 66 | DaysOfWeekTable.propTypes = { 67 | daysOfWeek: PropTypes.arrayOf(PropTypes.shape({ 68 | dayOfWeek: PropTypes.string.isRequired, 69 | })).isRequired, 70 | onChange: PropTypes.func.isRequired, 71 | }; 72 | 73 | export default DaysOfWeekTable; 74 | -------------------------------------------------------------------------------- /src/shared/components/TimeRange/TimeRange.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { startOfDay, add, format } from "date-fns"; 4 | import { Slider } from "@material-ui/core"; 5 | import { makeStyles } from "@material-ui/core/styles"; 6 | 7 | const TIME_FORMAT = "h:mm a"; 8 | 9 | const useStyles = makeStyles({ 10 | marked: { 11 | marginBottom: 0, 12 | marginTop: "1rem" 13 | }, 14 | valueLabel: { 15 | textAlign: "center", 16 | top: -22, 17 | "& *": { 18 | background: "transparent", 19 | color: "#000" 20 | } 21 | } 22 | }); 23 | 24 | function TimeRange({ value, onChange, step, disabled }) { 25 | const [dayTimes, setDayTimes] = useState([]); 26 | const [selectedValue, setSelectedValue] = useState([]); 27 | 28 | const classes = useStyles(); 29 | 30 | useEffect(() => { 31 | if (!value || value.length === 0) { 32 | onChange([dayTimes[0], dayTimes[dayTimes.length - 1]]); 33 | return; 34 | } 35 | 36 | const [start, end] = value; 37 | 38 | const startIndex = dayTimes.indexOf(start); 39 | const endIndex = dayTimes.indexOf(end); 40 | 41 | const val = [ 42 | startIndex, 43 | endIndex === startIndex && endIndex === 0 ? dayTimes.length - 1 : endIndex 44 | ]; 45 | setSelectedValue(val); 46 | // eslint-disable-next-line react-hooks/exhaustive-deps 47 | }, [value, dayTimes]); 48 | 49 | useEffect(() => { 50 | const start = startOfDay(new Date()); 51 | const end = startOfDay(add(start, { days: 1 })); 52 | 53 | const newDayTimes = []; 54 | 55 | let tempDate; 56 | for (tempDate = start; tempDate <= end; tempDate = add(tempDate, step)) { 57 | const formattedDate = format(tempDate, TIME_FORMAT); 58 | newDayTimes.push(formattedDate); 59 | } 60 | 61 | setDayTimes(newDayTimes); 62 | // eslint-disable-next-line react-hooks/exhaustive-deps 63 | }, [step.minutes]); 64 | 65 | function handleChange(e, val) { 66 | const [start, end] = val; 67 | onChange([dayTimes[start], dayTimes[end]]); 68 | } 69 | 70 | function valueLabelFormat(val) { 71 | return dayTimes[val]; 72 | } 73 | 74 | return ( 75 | 88 | ); 89 | } 90 | 91 | TimeRange.propTypes = { 92 | value: PropTypes.arrayOf(PropTypes.string), 93 | onChange: PropTypes.func, 94 | step: PropTypes.shape({ 95 | minutes: PropTypes.number 96 | }).isRequired, 97 | disabled: PropTypes.bool 98 | }; 99 | 100 | TimeRange.defaultProps = { 101 | disabled: false, 102 | onChange: () => {}, 103 | value: "" 104 | }; 105 | 106 | export default TimeRange; 107 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FieldModal/FieldTypeSelector.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Option, SelectField } from '@contentful/forma-36-react-components'; 4 | 5 | import AdditionalFields from './AdditionalFields'; 6 | 7 | const fieldTypes = [ 8 | { value: 'business-search', label: 'Business Search' }, 9 | { value: 'button', label: 'Button' }, 10 | { value: 'checkbox', label: 'Checkbox' }, 11 | { value: 'color', label: 'Color' }, 12 | { value: 'date', label: 'Date' }, 13 | { value: 'date-range', label: 'Date Range' }, 14 | { value: 'datetime', label: 'Date Time' }, 15 | { value: 'email', label: 'Email' }, 16 | { value: 'file', label: 'File' }, 17 | { value: 'hidden', label: 'Hidden' }, 18 | { value: 'hubspot', label: 'Hubspot' }, 19 | { value: 'image', label: 'Image' }, 20 | { value: 'month', label: 'Month' }, 21 | { value: 'number', label: 'Number' }, 22 | { value: 'password', label: 'Password' }, 23 | { value: 'radio', label: 'Radio' }, 24 | { value: 'range', label: 'Range' }, 25 | { value: 'required', label: 'Required' }, 26 | { value: 'reset', label: 'Reset' }, 27 | { value: 'search', label: 'Search' }, 28 | { value: 'select', label: 'Select' }, 29 | { value: 'string', label: 'String' }, 30 | { value: 'submit', label: 'Submit' }, 31 | { value: 'tel', label: 'Tel' }, 32 | { value: 'text', label: 'Text' }, 33 | { value: 'text-toggle', label: 'Text Toggle' }, 34 | { value: 'time', label: 'Time' }, 35 | { value: 'time-range', label: 'Time Range' }, 36 | { value: 'toggleable', label: 'Toggleable' }, 37 | { value: 'url', label: 'Url' }, 38 | { value: 'week', label: 'Week' }, 39 | { value: 'country', label: 'Country' }, 40 | { value: 'state', label: 'State (US)' } 41 | ]; 42 | 43 | function FieldTypeSelector({ errors, field, updateField }) { 44 | return ( 45 | <> 46 | 53 | updateField({ 54 | ...field, 55 | // Remove other stuff which was dependent on old field type 56 | schema: {}, 57 | options: undefined, 58 | type: e.currentTarget.value 59 | }) 60 | }> 61 | {fieldTypes.map(({ value: fieldType, label }) => ( 62 | 65 | ))} 66 | 67 | 68 | 69 | ); 70 | } 71 | 72 | FieldTypeSelector.propTypes = { 73 | updateField: PropTypes.func.isRequired, 74 | field: PropTypes.shape({ 75 | type: PropTypes.string, 76 | options: PropTypes.arrayOf(PropTypes.object) 77 | }).isRequired, 78 | 79 | // eslint-disable-next-line react/forbid-prop-types 80 | errors: PropTypes.object 81 | }; 82 | 83 | FieldTypeSelector.defaultProps = { 84 | errors: [] 85 | }; 86 | 87 | export default FieldTypeSelector; 88 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/hooks/useProviderConfig.test.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { clone, set } from 'lodash'; 3 | import { renderHook, act } from '@testing-library/react-hooks'; 4 | 5 | import useProviderConfig from './useProviderConfig'; 6 | 7 | function getProviderHook(defaultProvider = {}) { 8 | return renderHook(() => { 9 | const [internalState, setInternalState] = useState({ provider: defaultProvider }); 10 | 11 | const setState = (key, value) => setInternalState(set(clone(internalState), key, value)); 12 | 13 | return useProviderConfig(setState, internalState); 14 | }); 15 | } 16 | 17 | describe('useProviderConfig', () => { 18 | const type = 'test-type'; 19 | const formId = 'test-formId'; 20 | const portalId = 'test-portalId'; 21 | 22 | const defaultProvider = { type, parameters: { formId, portalId } }; 23 | const defaultResult = { type, formId, portalId }; 24 | 25 | it('loads current provider', () => { 26 | const { result } = getProviderHook(defaultProvider); 27 | expect(result.current).toMatchObject(defaultResult); 28 | }); 29 | 30 | it('can change formId', () => { 31 | const { result } = getProviderHook(defaultProvider); 32 | act(() => result.current.setFormId('test-change')); 33 | expect(result.current.formId).toEqual('test-change'); 34 | }); 35 | 36 | it('can change portalId', () => { 37 | const { result } = getProviderHook(defaultProvider); 38 | act(() => result.current.setPortalId('test-change')); 39 | expect(result.current.portalId).toEqual('test-change'); 40 | }); 41 | 42 | it('can change type', () => { 43 | const { result } = getProviderHook(defaultProvider); 44 | 45 | act(() => result.current.setFormId('custom')); 46 | act(() => result.current.setPortalId('custom')); 47 | 48 | expect(result.current.formId).toEqual('custom'); 49 | expect(result.current.portalId).toEqual('custom'); 50 | 51 | act(() => result.current.setType('custom')); 52 | 53 | expect(result.current.type).toEqual('custom'); 54 | 55 | // These should be removed when changing the type 56 | expect(result.current.formId).toEqual(null); 57 | expect(result.current.portalId).toEqual(null); 58 | }); 59 | 60 | it('preserves properties when changing to another url type', () => { 61 | const { result } = getProviderHook(defaultProvider); 62 | 63 | act(() => result.current.setFormId('custom')); 64 | act(() => result.current.setPortalId('custom')); 65 | 66 | act(() => result.current.setType('hubspot')); 67 | 68 | expect(result.current.type).toEqual('hubspot'); 69 | 70 | // These should be removed when changing the type 71 | expect(result.current.formId).toEqual('custom'); 72 | expect(result.current.portalId).toEqual('custom'); 73 | }); 74 | 75 | it('can update state', () => { 76 | const { result } = getProviderHook(defaultProvider); 77 | 78 | act(() => result.current.update({ parameters: { formId: 'test', portalId: 'test2' }, type: 'hubspot' })); 79 | 80 | expect(result.current).toMatchObject({ formId: 'test', portalId: 'test2', type: 'hubspot' }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FieldModal/FieldEditor.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/forbid-prop-types */ 2 | 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | import PropTypes from 'prop-types'; 6 | import { FieldGroup, TextField } from '@contentful/forma-36-react-components'; 7 | import DependsOn from '../StepList/DependsOn'; 8 | 9 | import SchemaEditor from './SchemaEditor'; 10 | import FieldTypeSelector from './FieldTypeSelector'; 11 | 12 | import { errorOfType, errorTypes } from '../validate'; 13 | import { WarningStyle } from '../StepList/styles'; 14 | 15 | const FieldEditorWrapper = styled.div` 16 | > * { 17 | margin-bottom: 1rem; 18 | } 19 | `; 20 | 21 | function FieldEditor({ errors, field, updateField }) { 22 | const errorsForField = errors[field.id]; 23 | const nameError = errorOfType(errorTypes.CONFLICT_NAME, errorsForField); 24 | 25 | return ( 26 | 27 | updateField('label', e.currentTarget.value)} 34 | /> 35 | 36 | updateField('name', e.currentTarget.value)} 43 | /> 44 | {nameError && {nameError.message}} 45 | 46 | {field.type !== 'hidden' && ( 47 | updateField('placeholder', e.currentTarget.value)} 53 | /> 54 | )} 55 | 56 | 57 | updateField('dependsOn', newValue)} 61 | onChangeTests={(newValue) => updateField('dependsOnTests', newValue)} 62 | /> 63 | 64 | ); 65 | } 66 | 67 | FieldEditor.propTypes = { 68 | field: PropTypes.shape({ 69 | id: PropTypes.string, 70 | type: PropTypes.string, 71 | label: PropTypes.string, 72 | name: PropTypes.string, 73 | placeholder: PropTypes.string, 74 | dependsOn: PropTypes.string, 75 | dependsOnTests: PropTypes.arrayOf(PropTypes.string) 76 | }).isRequired, 77 | updateField: PropTypes.func.isRequired, 78 | 79 | /* eslint-disable react/forbid-prop-types */ 80 | errors: PropTypes.object, // containing { [id]: error } 81 | dependsOn: PropTypes.object, 82 | dependsOnTests: PropTypes.arrayOf(PropTypes.object) 83 | /* eslint-enable react/forbid-prop-types */ 84 | }; 85 | 86 | FieldEditor.defaultProps = { 87 | errors: {}, 88 | dependsOn: {}, 89 | dependsOnTests: [] 90 | }; 91 | 92 | export default FieldEditor; 93 | -------------------------------------------------------------------------------- /src/extensions/FormStack/FormStack.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup, act } from '@testing-library/react'; 3 | import { waitFor } from '@testing-library/dom'; 4 | import 'react-select'; 5 | import axios from 'axios'; 6 | import FormStack from './FormStack'; 7 | import sdk from './mockSdk'; 8 | import mockForms from './__mocks__/mockForms'; 9 | 10 | let mockData; 11 | let formLength; 12 | jest.mock('axios'); 13 | jest.mock('react-select', () => ({ options, value, onChange }) => { 14 | function handleChange(event) { 15 | const option = options.find((opt) => opt.value === event.currentTarget.value); 16 | onChange(option); 17 | } 18 | return ( 19 | // eslint-disable-next-line jsx-a11y/no-onchange 20 | 30 | ); 31 | }); 32 | 33 | beforeEach(() => { 34 | formLength = 5; 35 | mockData = mockForms(formLength); 36 | const response = { data: { forms: mockData } }; 37 | axios.mockImplementation(() => Promise.resolve(response)); 38 | }); 39 | 40 | afterEach(() => { 41 | cleanup(); 42 | }); 43 | 44 | describe('', () => { 45 | describe(' renders', () => { 46 | test('renders correctly', async () => { 47 | await act(async () => { 48 | const { getByTestId } = render(); 49 | expect(getByTestId('formstack-select')).toBeTruthy(); 50 | }); 51 | }); 52 | 53 | test('renders correct options', async () => { 54 | const mockedData = mockData; 55 | await act(async () => { 56 | const { getAllByTestId } = render(); 57 | await waitFor(() => expect(getAllByTestId('formstack-select-item').length).toBe(formLength)); 58 | 59 | expect( 60 | getAllByTestId('formstack-select-item').every((item, index) => item.textContent === mockedData[index].name) 61 | ).toBeTruthy(); 62 | expect( 63 | getAllByTestId('formstack-select-item').every((item, index) => item.value === mockedData[index].id.toString()) 64 | ).toBeTruthy(); 65 | }); 66 | }); 67 | 68 | test('renders correct default display and value', async () => { 69 | const mockedData = mockData; 70 | const defaultForm = mockedData[0]; 71 | const defaultId = defaultForm && defaultForm.id && defaultForm.id.toString(); 72 | const defaultName = defaultForm && defaultForm.name; 73 | sdk.field.getValue = jest.fn(() => ({ formId: defaultId, formName: defaultName })); 74 | 75 | await act(async () => { 76 | const { getByTestId, getByDisplayValue } = render(); 77 | await waitFor(() => expect(getByTestId('formstack-select').value).toBe(defaultId)); 78 | expect(getByDisplayValue(defaultName)).toBeTruthy(); 79 | }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/extensions/ColorPicker/ColorPicker.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup, fireEvent } from '@testing-library/react'; 3 | import ColorPicker from './ColorPicker'; 4 | 5 | import sdk from './mockSdk'; 6 | 7 | afterEach(() => { 8 | cleanup(); 9 | }); 10 | 11 | describe('', () => { 12 | // Ensure the component fails correctly 13 | test.todo('shows error message when sdk not present'); 14 | 15 | test('buttons have the required attributes', () => { 16 | const { container } = render(); 17 | const button = container.firstChild.querySelector('button'); 18 | expect(button.getAttribute('type')) 19 | .toEqual('button'); 20 | expect(button.getAttribute('data-hex')) 21 | .not.toBeNull(); 22 | }); 23 | 24 | test('render the same number of buttons as validations', () => { 25 | const { getAllByTestId } = render(); 26 | expect(getAllByTestId('ColorPicker-button').length) 27 | .toBe(sdk.field.validations[0].in.length); 28 | }); 29 | 30 | test('add the class active if the value matches the hex', () => { 31 | const { container } = render(); 32 | expect(container.firstChild.querySelectorAll('.active').length) 33 | .toBe(1); 34 | expect(container.firstChild.querySelector('.active').getAttribute('data-hex')) 35 | .toEqual(sdk.field.getValue()); 36 | }); 37 | 38 | test('no button with active class if there is not a value for the field', () => { 39 | const { container } = render( '', 43 | } 44 | }} />); 45 | expect(container.firstChild.querySelectorAll('.active').length) 46 | .toBe(0); 47 | }); 48 | 49 | test('adds active class to correct button on click event', () => { 50 | const handleColorChange = jest.fn(); 51 | const { container, getAllByTestId } = render( 52 | ); 58 | 59 | fireEvent.click(getAllByTestId('ColorPicker-button')[1]); 60 | expect(handleColorChange) 61 | .toBeCalledTimes(1); 62 | expect(container.firstChild.querySelectorAll('.active').length) 63 | .toBe(1); 64 | expect(container.firstChild.querySelector('.active') 65 | .getAttribute('data-hex')) 66 | .toEqual(sdk.field.validations[0].in[1]); 67 | }); 68 | 69 | test('adds active class to correct button on keydown event', () => { 70 | const handleColorChange = jest.fn(); 71 | const { container, getAllByTestId } = render(); 77 | 78 | fireEvent.keyDown(getAllByTestId('ColorPicker-button')[1]); 79 | expect(handleColorChange) 80 | .toBeCalledTimes(1); 81 | expect(container.firstChild.querySelectorAll('.active').length) 82 | .toBe(1); 83 | expect(container.firstChild.querySelector('.active') 84 | .getAttribute('data-hex')) 85 | .toEqual(sdk.field.validations[0].in[1]); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/extensions/Bynder/Bynder.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Button } from "@contentful/forma-36-react-components"; 4 | import "./Bynder.scss"; 5 | 6 | const CTA = "Select a file on Bynder"; 7 | 8 | function Bynder({ sdk, locations }) { 9 | const [thumbnail, setThumbnail] = useState(null); 10 | 11 | function makeThumbnail(resource) { 12 | const url = resource.files.webImage && resource.files.webImage.url; 13 | const alt = [resource.id, ...(resource.tags || [])].join(", "); 14 | 15 | setThumbnail({ url, alt }); 16 | } 17 | 18 | useEffect(() => { 19 | if (sdk.location.is(locations.LOCATION_DIALOG)) { 20 | return; 21 | } 22 | 23 | if (sdk.field.getValue()) { 24 | makeThumbnail(sdk.field.getValue().asset); 25 | } else { 26 | setThumbnail(null); 27 | } 28 | }, [sdk.field, sdk.location, locations.LOCATION_DIALOG]); 29 | 30 | function setSelectedFiles(assets, selectedFile) { 31 | sdk.close({ asset: assets[0], selectedFile: selectedFile.selectedFile }); 32 | } 33 | 34 | function renderDialog() { 35 | const { domain = {} } = sdk.parameters.installation; 36 | 37 | window.BynderCompactView.open({ 38 | assetTypes: ["image", "audio", "document", "video"], 39 | mode: "SingleSelectFile", 40 | onSuccess: setSelectedFiles, 41 | portal: { 42 | url: domain.url, 43 | editable: domain.editable 44 | } 45 | }); 46 | 47 | return <>; 48 | } 49 | 50 | async function openDialog() { 51 | const result = await sdk.dialogs.openExtension({ 52 | position: "center", 53 | title: CTA, 54 | width: 1400, 55 | minHeight: 800, 56 | allowHeightOverflow: true, 57 | shouldCloseOnOverlayClick: true, 58 | shouldCloseOnEscapePress: true 59 | }); 60 | 61 | if (result && result.asset) { 62 | sdk.field.setValue({ 63 | asset: result.asset, 64 | selectedFile: result.selectedFile 65 | }); 66 | 67 | makeThumbnail(result.asset); 68 | } 69 | } 70 | 71 | if (sdk.location.is(locations.LOCATION_DIALOG)) { 72 | return renderDialog(); 73 | } 74 | 75 | return ( 76 |
    77 | {thumbnail && ( 78 |
    79 | {thumbnail.alt} 80 |
    81 | )} 82 | 85 |
    86 | ); 87 | } 88 | 89 | Bynder.propTypes = { 90 | locations: PropTypes.shape({ 91 | LOCATION_DIALOG: PropTypes.string 92 | }).isRequired, 93 | sdk: PropTypes.shape({ 94 | close: PropTypes.func, 95 | dialogs: PropTypes.shape({ 96 | openExtension: PropTypes.func 97 | }), 98 | field: PropTypes.shape({ 99 | getValue: PropTypes.func, 100 | setValue: PropTypes.func 101 | }), 102 | location: PropTypes.shape({ 103 | is: PropTypes.func 104 | }), 105 | parameters: PropTypes.shape({ 106 | installation: PropTypes.object 107 | }) 108 | }).isRequired 109 | }; 110 | 111 | export default Bynder; 112 | -------------------------------------------------------------------------------- /src/extensions/FormBuilder/FormInfo/modal2: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import { Modal, Form, FieldGroup, FormLabel, TextInput, Button } from '@contentful/forma-36-react-components'; 5 | import arrayMove from 'array-move'; 6 | import SortableList from '../SortableList'; 7 | import ConfirmDeleteDialog from '../ConfirmDeleteDialog'; 8 | 9 | const SetupStepPropTypes = { 10 | step: PropTypes.shape({ 11 | id: PropTypes.string.isRequired, 12 | title: PropTypes.string.isRequired 13 | }), 14 | onClose: PropTypes.func.isRequired, 15 | onSubmit: PropTypes.func.isRequired 16 | }; 17 | 18 | const SetupStep = ({ step, onClose, onSubmit }) => { 19 | const [values, setValues] = useState(); 20 | const [removeField, setRemoveField] = useState(); 21 | 22 | const handleSubmit = () => { 23 | onSubmit(values); 24 | onClose(); 25 | }; 26 | 27 | const handleChange = (field) => (event) => { 28 | const { value } = event.target; 29 | setValues((prev) => ({ ...prev, [field]: value })); 30 | }; 31 | 32 | const handleSortEnd = ({ oldIndex, newIndex }) => { 33 | setValues((prev) => ({ 34 | ...prev, 35 | fields: arrayMove(prev.fields, oldIndex, newIndex) 36 | })); 37 | }; 38 | 39 | const handleOpenRemoveField = (field) => () => { 40 | setRemoveField(field); 41 | }; 42 | 43 | useEffect(() => { 44 | setValues(step); 45 | }, [step]); 46 | 47 | if (!values) return null; 48 | return ( 49 | <> 50 | 51 |
    52 | 53 | Step Name 54 | 55 | 56 | 57 | Fields 58 | {}} 63 | /> 64 | 65 | 66 | 69 | 70 | 71 |
    72 | 75 | 82 |
    83 |
    84 |
    85 |
    86 | 87 | 88 | ); 89 | }; 90 | 91 | SetupStep.propTypes = SetupStepPropTypes; 92 | 93 | SetupStep.defaultProps = { 94 | step: undefined 95 | }; 96 | 97 | export default SetupStep; 98 | -------------------------------------------------------------------------------- /src/extensions/OperatingHours/FriendlyLabelsTable/EditForm.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Button, Card, FieldGroup, Form, TextField } from '@contentful/forma-36-react-components'; 4 | 5 | function EditForm({ value, onSubmit, onCancel }) { 6 | const [period, setPeriod] = useState(''); 7 | const [description, setDescription] = useState(''); 8 | 9 | function clearData() { 10 | setPeriod(''); 11 | setDescription(''); 12 | } 13 | 14 | useEffect(() => { 15 | if (value) { 16 | setPeriod(value.period); 17 | setDescription(value.description); 18 | } else { 19 | clearData(); 20 | } 21 | }, [value]); 22 | 23 | function submit() { 24 | onSubmit({ period, description }); 25 | clearData(); 26 | } 27 | 28 | return ( 29 | <> 30 | 31 |
    35 |
    36 | 37 | setPeriod(e.target.value)} 43 | required 44 | testId="friendlyLabelsForm-period" /> 45 | 46 | 47 | setDescription(e.target.value)} 53 | testId="friendlyLabelsForm-description" /> 54 | 55 |
    56 | { 57 | value ? ( 58 | <> 59 | 65 | 73 | 74 | ) : ( 75 | 81 | ) 82 | } 83 |
    84 |
    85 | 86 | ); 87 | } 88 | 89 | EditForm.propTypes = { 90 | value: PropTypes.shape({ 91 | period: PropTypes.string, 92 | description: PropTypes.string 93 | }), 94 | onSubmit: PropTypes.func.isRequired, 95 | onCancel: PropTypes.func.isRequired 96 | }; 97 | 98 | EditForm.defaultProps = { 99 | value: null 100 | }; 101 | 102 | export default EditForm; 103 | -------------------------------------------------------------------------------- /src/extensions/FormStack/FormStack.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import Select from 'react-select'; 4 | import PropTypes from 'prop-types'; 5 | import { withLabel } from '../../shared/helpers/formControl'; 6 | 7 | const API_URL = process.env.REACT_APP_FORMSTACK_FORMS_URI; 8 | 9 | const labelText = 'Chose your Formstack Form'; 10 | 11 | const FormStack = ({ sdk }) => { 12 | const [fieldValue, setFieldValue] = useState(undefined); 13 | const [allForms, setAllForms] = useState([]); 14 | const [initialized, setInitialized] = useState(false); 15 | 16 | useEffect(() => { 17 | const fetchData = async () => { 18 | setInitialized(true); 19 | const response = await axios(API_URL); 20 | const { forms } = response.data; 21 | const getFormName = (id) => { 22 | const form = (forms || []).filter((f) => f.id.toString() === id)[0]; 23 | return form && form.name; 24 | }; 25 | setAllForms(forms); 26 | const value = sdk.field.getValue(); 27 | if (value && value.formId) { 28 | setFieldValue({ value: value.formId, label: getFormName(value.formId) }); 29 | } 30 | }; 31 | if (!initialized) { 32 | fetchData(); 33 | } 34 | }, [sdk.field, initialized]); 35 | 36 | const onSelectChange = (field) => { 37 | if (!field || !field.value || field.value === '') { 38 | setFieldValue(undefined); 39 | sdk.field.setValue({ formId: undefined, formName: undefined }); 40 | } else { 41 | setFieldValue(field); 42 | sdk.field.setValue({ formId: field.value, formName: field.label }); 43 | } 44 | }; 45 | 46 | const onMenuClose = () => { 47 | sdk.window.startAutoResizer(); 48 | }; 49 | 50 | const calculateHeight = () => { 51 | const defaultHeight = 95; 52 | const itemHeight = 35; 53 | const itemsLength = allForms && allForms.length > 1 ? allForms.length : 1; 54 | const multiplier = itemsLength > 9 ? 9 : itemsLength; 55 | return multiplier * itemHeight + defaultHeight; 56 | }; 57 | 58 | const onMenuOpen = () => { 59 | sdk.window.stopAutoResizer(); 60 | sdk.window.updateHeight(calculateHeight()); 61 | }; 62 | 63 | return ( 64 |
    65 | {withLabel('formstack-select', labelText, () => ( 66 |