├── .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("
${getValue(entry)}
9 |')
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 |
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 |
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 |
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 |
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