├── .gitignore ├── README.md ├── images ├── AppDefinition.gif ├── AppDefinition.png ├── AuthorizationScreen.png ├── DeepLinkInstallation.png ├── EntrySelection.gif ├── FieldConfiguration.png ├── Installation.png ├── Installation_w_SpaceConfig.png └── SpaceConfiguration.png ├── package-lock.json ├── package.json ├── public └── index.html ├── src ├── Types.ts ├── components │ ├── ConfigScreen.spec.tsx │ ├── ConfigScreen.tsx │ ├── Dialog.spec.tsx │ ├── Dialog │ │ ├── EntryPickerDialog.tsx │ │ ├── EntryPickerStyles.ts │ │ ├── SpaceConfigurationDialog.tsx │ │ └── index.tsx │ ├── EntryEditor.spec.tsx │ ├── EntryEditor.tsx │ ├── Field.spec.tsx │ ├── Field │ │ ├── CrossSpaceField │ │ │ ├── AdvisoryNote.tsx │ │ │ ├── CrossSpaceEntryActions.tsx │ │ │ └── CrossSpaceReferenceEditor.tsx │ │ └── index.tsx │ ├── Page.spec.tsx │ ├── Page.tsx │ ├── Sidebar.spec.tsx │ └── Sidebar.tsx ├── index.css ├── index.tsx ├── react-app-env.d.ts └── setupTests.ts └── tsconfig.json /.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 | build.* 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Contentful Cross Space References 2 | 3 | Contentful Cross Space References is a simple app that makes it easy to create a relationship between content in multiple Contentful spaces via the Contenful UI: 4 | 5 | ![Entry Selection Interactive Gif](images/EntrySelection.gif) 6 | 7 | --- 8 | 9 | ## Installation 10 | 11 | To use this app in your spaces, follow the below steps: 12 | 1. [Install the app to your space](https://app.contentful.com/deeplink?link=apps&id=1gIm0dTkt9n2mRaY7Shsc7) 13 | 2. Select the Space and Environment that you want to install the app in, then select "Continue" to be redirected to the Apps page in the selected space/environment: \ 14 | ![Space installation screen](images/DeepLinkInstallation.png) 15 | 3. Select "Authorize access" to allow Cross Space References app to access your space, after which you'll be redirected to the App Configuration screen: \ 16 | ![Authorization screen](images/AuthorizationScreen.png) 17 | 4. This app will only be able to be installed after you've defined at least one external space from which you'd like to create relationships to this space's content, so select the "Add Space Configuration" button: \ 18 | ![Installation Screen](images/Installation.png) 19 | 5. In the modal window, enter a Space ID and Content Delivery API token that will be used to retrieve Content from your external space as seen here 20 | (_Note: you will be unable to save this configuration if the Space ID and Token are invalid._): \ 21 | ![Space Configuration Screen](images/SpaceConfiguration.png). 22 | 6. Select Save in the modal window, at which point you'll see the external space listed in your App Configuration: \ 23 | ![Installation Screen w Space Configuration](images/Installation_w_SpaceConfig.png) 24 | 7. Select "Install". 25 | 1. _Note: Additional external spaces can be added after installation by returning to this screen via the App tab -> Manage Apps_ 26 | 2. _From the Apps management page, selecting the dropdown menu to the right of your Cross Space Configuration app and choose the Configure option._ 27 | 3. _Once back in the App Configuration Screen, you can add a new space configuration and select "Save" to update your new configurations._ 28 | 4. _You can also install this app in additional spaces from the App Managment tab of those spaces by selecting the Cross Space References app from the list of Available apps, then following steps 3-7 above. 29 | 30 | 31 | ### Configuring your Content Types 32 | 1. For every Content Type you'd like to be able to reference content from an external space, create a new "JSON Object" field, then set the appearance for this field to use the Cross Space Reference App: \ 33 | ![Field Appearance Configuration](images/FieldConfiguration.png) 34 | 35 | ## JSON Field Output 36 | When using the Cross Space References app, the reference will be stored as a JSON object with the following structure: 37 | 38 | ```json 39 | { 40 | "sys": { 41 | "type": "Link", 42 | "linkType": "CrossSpaceEntry", 43 | "id": "", 44 | "space": { 45 | "sys": { 46 | "type": "Link", 47 | "linkType": "Space", 48 | "id": "" 49 | } 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | To retrieve the entry data, make an API call to the appropriate space and query by entry id as described in our documentation: [Getting a single entry](https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/entries/entry/get-a-single-entry/console/curl) 56 | 57 | --- 58 | # Contributing 59 | 60 | To contribute back, fork this project and clone the repo, then run `npm install` in your local directory. 61 | 62 | This project was bootstrapped with [Create Contentful App](https://github.com/contentful/create-contentful-app). 63 | 64 | ## Available Scripts 65 | 66 | In the project directory, you can run: 67 | 68 | #### `npm start` 69 | 70 | Creates or update your app definition in contentful, and runs the app in development mode. 71 | Open your app to view it in the browser. 72 | 73 | The page will reload if you make edits. 74 | You will also see any lint errors in the console. 75 | 76 | #### `npm run build` 77 | 78 | Builds the app for production to the `build` folder. 79 | It correctly bundles React in production mode and optimizes the build for the best performance. 80 | 81 | The build is minified and the filenames include the hashes. 82 | Your app is ready to be deployed! 83 | 84 | ## Create the App Definition 85 | 1. Create a new app definition as described in [Building your first app](https://www.contentful.com/developers/docs/extensibility/app-framework/tutorial/#create-your-appdefinition). 86 | 2. Give your app a name (such as Cross Space References). 87 | 3. Configure your app: 88 | 1. Set the frontend url of your app to the local development url from `npm run start` (http://localhost:3000 by default), or Select "Hosted by Contentful" and upload the output of `npm run build` 89 | 2. Select the checkboxes for "App Configuration Screen" and "Entry Field", then select the checkbox for "JSON Object" below "Entry Field" 90 | 4. Select "Save", then select "Save" in the confirmation modal that appears. 91 | 92 | ## Configure and Install the App to your Space 93 | 1. From the App Definition window, select the down arrow next to "Actions", then select "Install to space". 94 | 2. Select a Space and Environment where you'd like to install this app in the modal window that appears, then select "Continue". 95 | 3. Authorize the Cross Space References app for your space, after which you'll be redirected to the App Configuration screen, as seen here: \ 96 | ![Installation Screen](images/Installation.png) 97 | 4. Follow installation steps above, beginning with step 4. 98 | 99 | ## Create a PR 100 | Make any necessary changes then submit a PR to contribute the changes back to CF. 101 | -------------------------------------------------------------------------------- /images/AppDefinition.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamdev/contentful-cross-space-references/e404ffc463acc946fb627bcec8826318ba202d88/images/AppDefinition.gif -------------------------------------------------------------------------------- /images/AppDefinition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamdev/contentful-cross-space-references/e404ffc463acc946fb627bcec8826318ba202d88/images/AppDefinition.png -------------------------------------------------------------------------------- /images/AuthorizationScreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamdev/contentful-cross-space-references/e404ffc463acc946fb627bcec8826318ba202d88/images/AuthorizationScreen.png -------------------------------------------------------------------------------- /images/DeepLinkInstallation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamdev/contentful-cross-space-references/e404ffc463acc946fb627bcec8826318ba202d88/images/DeepLinkInstallation.png -------------------------------------------------------------------------------- /images/EntrySelection.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamdev/contentful-cross-space-references/e404ffc463acc946fb627bcec8826318ba202d88/images/EntrySelection.gif -------------------------------------------------------------------------------- /images/FieldConfiguration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamdev/contentful-cross-space-references/e404ffc463acc946fb627bcec8826318ba202d88/images/FieldConfiguration.png -------------------------------------------------------------------------------- /images/Installation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamdev/contentful-cross-space-references/e404ffc463acc946fb627bcec8826318ba202d88/images/Installation.png -------------------------------------------------------------------------------- /images/Installation_w_SpaceConfig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamdev/contentful-cross-space-references/e404ffc463acc946fb627bcec8826318ba202d88/images/Installation_w_SpaceConfig.png -------------------------------------------------------------------------------- /images/SpaceConfiguration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamdev/contentful-cross-space-references/e404ffc463acc946fb627bcec8826318ba202d88/images/SpaceConfiguration.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cra-cross-space-references", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@contentful/app-sdk": "^3.32.1", 7 | "@contentful/field-editor-reference": "^2.2.1", 8 | "@contentful/field-editor-test-utils": "^0.8.1", 9 | "@contentful/forma-36-fcss": "^0.2.7", 10 | "@contentful/forma-36-react-components": "^3.72.7", 11 | "@contentful/forma-36-tokens": "^0.10.2", 12 | "@testing-library/jest-dom": "^5.11.4", 13 | "@testing-library/react": "^11.0.4", 14 | "@testing-library/user-event": "^12.1.5", 15 | "@types/jest": "^26.0.14", 16 | "@types/node": "^14.10.3", 17 | "@types/react": "^16.9.49", 18 | "@types/react-dom": "^16.9.8", 19 | "contentful": "^7.14.6", 20 | "contentful-ui-extensions-sdk": "^3.23.2", 21 | "react": "^16.13.1", 22 | "react-dom": "^16.13.1", 23 | "react-scripts": "3.4.3", 24 | "typescript": "^4.0.2" 25 | }, 26 | "scripts": { 27 | "start": "react-scripts start", 28 | "build": "react-scripts build", 29 | "test": "react-scripts test", 30 | "eject": "react-scripts eject" 31 | }, 32 | "eslintConfig": { 33 | "extends": "react-app" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "homepage": "." 48 | } 49 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 18 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Types.ts: -------------------------------------------------------------------------------- 1 | import { SpaceLink, Entry, ContentType, Locale } from 'contentful' 2 | import { FieldExtensionSDK } from '@contentful/app-sdk' 3 | 4 | export interface SpaceConfiguration { 5 | [name:string]: string; 6 | id: string; 7 | token: string; 8 | } 9 | 10 | export interface CrossSpaceLink { 11 | type: 'Link', 12 | linkType: 'CrossSpaceLink', 13 | id: string, 14 | space: { 15 | sys: SpaceLink 16 | } 17 | } 18 | 19 | export interface CrossSpaceEntryData { 20 | entry: Entry; 21 | contentType: ContentType; 22 | defaultLocale: Locale; 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/components/ConfigScreen.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ConfigScreen from './ConfigScreen'; 3 | import { render } from '@testing-library/react'; 4 | import userEvent from '@testing-library/user-event'; 5 | 6 | describe('Config Screen component', () => { 7 | it('Component text exists', async () => { 8 | const mockSdk: any = { 9 | app: { 10 | onConfigure: jest.fn(), 11 | getParameters: jest.fn().mockReturnValueOnce({}), 12 | setReady: jest.fn(), 13 | getCurrentState: jest.fn() 14 | } 15 | }; 16 | const { getByText } = render(); 17 | 18 | // simulate the user clicking the install button 19 | const configurationData = await mockSdk.app.onConfigure.mock.calls[0][0](); 20 | 21 | expect( 22 | getByText('Welcome to your contentful app. This is your config page.') 23 | ).toBeInTheDocument(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/ConfigScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import { AppExtensionSDK } from '@contentful/app-sdk'; 4 | import { 5 | Heading, Form, Workbench, Paragraph, Note, 6 | Button, IconButton, Dropdown, DropdownList, DropdownListItem, 7 | Table, TableHead, TableCell, TableRow, TableBody 8 | } from '@contentful/forma-36-react-components'; 9 | 10 | import { SpaceConfiguration } from '../Types' 11 | 12 | interface ConfigProps { 13 | sdk: AppExtensionSDK; 14 | } 15 | 16 | interface InstallationParams { 17 | spaceConfigs?: SpaceConfiguration[] 18 | } 19 | 20 | interface SpaceConfigRowProps { 21 | spaceConfig:SpaceConfiguration; 22 | onEdit: (id:string) => void; 23 | onDelete: (id:string) => void 24 | } 25 | 26 | const ConfigScreen = (props: ConfigProps) => { 27 | props.sdk.app.onConfigure(() => onConfigure()); 28 | 29 | const [spaceConfigs, setSpaceConfigs] = useState([]) 30 | 31 | const onConfigure = async() => { 32 | // Check that we have valid installation parameters. 33 | if (!spaceConfigs.length) { 34 | props.sdk.notifier.error('You must add at least one Cross-Space configuration to install this application.') 35 | return false; 36 | } 37 | 38 | // Get current the state of EditorInterface and other entities 39 | // related to this app installation 40 | const currentState:any = await props.sdk.app.getCurrentState(); 41 | 42 | return { 43 | // Parameters to be persisted as the app configuration. 44 | parameters: { spaceConfigs: spaceConfigs }, 45 | // In case you don't want to submit any update to app 46 | // locations, you can just pass the currentState as is 47 | targetState: currentState, 48 | }; 49 | } 50 | 51 | const onSpaceConfigEdit = (id:string) => { 52 | let spaceConfigIndex = spaceConfigs.findIndex((config:SpaceConfiguration) => config.id === id) 53 | 54 | if (spaceConfigIndex !== -1) { 55 | props.sdk.dialogs.openCurrentApp({ 56 | position: 'center', 57 | title: `Edit ${spaceConfigs[spaceConfigIndex].name} Space Configuration`, 58 | parameters: { 59 | dialog: 'SpaceConfiguration', 60 | props: { 61 | spaceConfig: spaceConfigs[spaceConfigIndex] 62 | } 63 | } 64 | }) 65 | .then((updatedConfig:SpaceConfiguration) => { 66 | if (updatedConfig) { 67 | let updatedConfigs = [...spaceConfigs] 68 | 69 | updatedConfigs[spaceConfigIndex] = updatedConfig 70 | setSpaceConfigs(updatedConfigs) 71 | } 72 | }) 73 | } 74 | } 75 | 76 | const onSpaceConfigDelete = (id:string) => { 77 | let spaceConfigIndex = spaceConfigs.findIndex((config:SpaceConfiguration) => config.id === id) 78 | if (spaceConfigIndex !== -1) { 79 | props.sdk.dialogs.openConfirm({ 80 | title: "Delete", 81 | message: "Are you sure?", 82 | intent: "positive", 83 | confirmLabel: "Delete", 84 | cancelLabel: "Cancel" 85 | }) 86 | .then((res:boolean) => { 87 | if (res) { 88 | let updatedConfigs = [...spaceConfigs] 89 | 90 | updatedConfigs.splice(spaceConfigIndex, 1) 91 | setSpaceConfigs(updatedConfigs) 92 | } 93 | }) 94 | } 95 | } 96 | 97 | const addSpaceConfiguration = () => { 98 | props.sdk.dialogs.openCurrentApp({ 99 | position: 'center', 100 | title: 'Add Space Configuration', 101 | parameters: { 102 | dialog: 'SpaceConfiguration', 103 | props: {} 104 | } 105 | }) 106 | .then((config:SpaceConfiguration) => { 107 | if (config) { 108 | setSpaceConfigs([...spaceConfigs, config]) 109 | } 110 | }) 111 | } 112 | 113 | useEffect(() => { 114 | 115 | //Populate SpaceConfigs with any saved values. 116 | const getSavedConfigs = async () => { 117 | let params = await props.sdk.app.getParameters() as InstallationParams | null 118 | if (params && params.spaceConfigs) { 119 | setSpaceConfigs(params.spaceConfigs) 120 | } 121 | } 122 | 123 | getSavedConfigs().then(() => props.sdk.app.setReady()) 124 | }, [props.sdk.app]) 125 | 126 | const tableHeaderCells = ['Space Name', 'Space ID', 'CDA Token', 'Operations'] 127 | 128 | return ( 129 | 130 |
131 | 132 | The Cross-space references app allows you to search and select entries from your other spaces, 133 | then references the entries within a single space. This enables you to access content from across all of your organizations. 134 | The cross-space references app aims to help you create consistent content efficiently. 135 | 136 | Space Configuration 137 | Configure which spaces you would like to allow cross-space references to: 138 | 139 | {spaceConfigs.length && 140 | 141 | 142 | 143 | {tableHeaderCells.map((header:string) => ( 144 | {header} 145 | ))} 146 | 147 | 148 | 149 | {spaceConfigs.map((config:SpaceConfiguration) => ( 150 | 151 | ))} 152 | 153 |
154 | } 155 |
156 |
157 | ) 158 | }; 159 | 160 | const SpaceConfigRow = (props:SpaceConfigRowProps) => { 161 | let spaceConfig = props.spaceConfig 162 | return ( 163 | 164 | {spaceConfig.name} 165 | {spaceConfig.id} 166 | {spaceConfig.token} 167 | 168 | 169 | 170 | 171 | ) 172 | } 173 | 174 | const SpaceConfigRowDropdown = (props:SpaceConfigRowProps) => { 175 | const [dropdownState, setDropdownState] = useState(false) 176 | 177 | const onEdit = () => { 178 | setDropdownState(!dropdownState) 179 | props.onEdit(id) 180 | } 181 | 182 | const onDelete = () => { 183 | setDropdownState(!dropdownState) 184 | props.onDelete(id) 185 | } 186 | 187 | let id = props.spaceConfig.id 188 | return ( 189 | setDropdownState(!dropdownState)} 197 | /> 198 | } 199 | > 200 | 201 | Edit 202 | Delete 203 | 204 | 205 | ) 206 | } 207 | 208 | export default ConfigScreen 209 | -------------------------------------------------------------------------------- /src/components/Dialog.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Dialog from './Dialog'; 3 | import { render } from '@testing-library/react'; 4 | 5 | describe('Dialog component', () => { 6 | it('Component text exists', () => { 7 | const { getByText } = render(); 8 | 9 | expect(getByText('Hello Dialog Component')).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/Dialog/EntryPickerDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | 3 | import { DialogExtensionSDK } from '@contentful/app-sdk' 4 | import { createClient, ContentType, Entry, ContentfulClientApi } from 'contentful' 5 | import { Modal, TextInput, Button, EntryCard, Paragraph, Spinner, Icon } from '@contentful/forma-36-react-components' 6 | 7 | import { SpaceConfiguration } from '../../Types' 8 | 9 | import * as EntryPickerStyles from './EntryPickerStyles' 10 | 11 | interface EntryPickerProps { 12 | sdk: DialogExtensionSDK; 13 | spaceConfig: SpaceConfiguration; 14 | } 15 | 16 | interface EntryListProps { 17 | entries: Entry[]; 18 | contentTypes: ContentType[]; 19 | query: string 20 | selectedEntry?: Entry; 21 | onSelect: (entry:Entry) => void; 22 | } 23 | 24 | interface EntryListItemProps { 25 | entry: Entry; 26 | contentType: ContentType | undefined; 27 | selectedEntry?: Entry; 28 | onSelect: (entry:Entry) => void; 29 | } 30 | 31 | const EntryPickerDialog = (props:EntryPickerProps) => { 32 | const spaceConfig = props.spaceConfig 33 | 34 | const [client, setClient] = useState() 35 | const [contentTypes, setContentTypes] = useState([]) 36 | const [selectedContentType, setSelectedContentType] = useState('any') 37 | const [query, setQuery] = useState('') 38 | const [loading, setLoading] = useState(false) 39 | const [entries, setEntries] = useState[]>([]) 40 | const [selected, setSelected] = useState>() 41 | 42 | const onContentTypeChange = (e:React.ChangeEvent) => { 43 | setSelectedContentType(e.target.value) 44 | } 45 | 46 | const onQueryChange = (e:React.ChangeEvent) => { 47 | setQuery(e.target.value) 48 | } 49 | 50 | const compareContentTypesByName = (a: ContentType, b: ContentType) => a.name.localeCompare(b.name) 51 | 52 | useEffect(() => { 53 | let init = createClient({ 54 | space: spaceConfig.id, 55 | accessToken: spaceConfig.token, 56 | }) 57 | 58 | setClient(init) 59 | }, [spaceConfig]) 60 | 61 | // Grab content types for a given space. 62 | useEffect(() => { 63 | if (client) { 64 | client.getContentTypes() 65 | .then(res => setContentTypes(res.items.sort(compareContentTypesByName))) 66 | } 67 | 68 | }, [client]) 69 | 70 | // Grab content based on search params, with a delay of 500ms 71 | useEffect(() => { 72 | console.log(selectedContentType) 73 | const timeOutId = setTimeout(() => { 74 | if (query.length && client !== undefined) { 75 | setLoading(true) 76 | 77 | const params:any = { 78 | query: query, 79 | content_type: selectedContentType !== 'any' ? selectedContentType : undefined 80 | } 81 | 82 | client.getEntries(params) 83 | .then(res => { 84 | setLoading(false) 85 | setEntries(res.items) 86 | }) 87 | } 88 | }, 500) 89 | return () => clearTimeout(timeOutId) 90 | }, [query, selectedContentType, client]) 91 | 92 | return ( 93 |
94 | props.sdk.close()} /> 95 | 96 | 97 | 105 | 112 | 113 | 114 | 115 | 116 | 117 |
118 | ) 119 | } 120 | 121 | // EntitySelectorTopBar.tsx 122 | const EntryPickerTopBar = () => { 123 | return ( 124 |
125 | Search for an entry: 126 |
127 | ) 128 | } 129 | 130 | // EntitySelectorSearch.tsx 131 | const EntryPickerSearch = (props:any) => { 132 | let styles = EntryPickerStyles.search 133 | 134 | return ( 135 |
136 |
137 |
138 |
139 | 140 | 146 |
147 | {props.isLoading && } 148 |
149 |
150 |
151 | ) 152 | } 153 | 154 | const EntryPickerContentTypePill = (props:any) => { 155 | let styles = EntryPickerStyles.filterPill 156 | return ( 157 |
158 |
Content type
159 | 160 |
161 | ) 162 | } 163 | 164 | const EntryPickerContentTypeSelect = (props:{ 165 | contentTypes:ContentType[]; 166 | selectValue: string; 167 | onSelectChange: () => void; 168 | }) => { 169 | let styles = EntryPickerStyles.selectValueInput 170 | 171 | const getSelectWidth = (label:string) => { 172 | const width = label.length + 5; 173 | let value = Math.max(7, width); 174 | if (value > 20) { 175 | value = 20; 176 | } 177 | return value.toString() + 'ch'; 178 | } 179 | 180 | return ( 181 |
182 |
183 | 197 | 198 | 199 | 200 |
201 |
202 | ) 203 | } 204 | 205 | const EntryList = (props:EntryListProps) => { 206 | let styles = EntryPickerStyles.entryList 207 | 208 | return ( 209 |
210 | {props.entries.length ? ( 211 | <> 212 | {props.entries.map((entry:Entry) => ( 213 |
214 | ct.sys.id === entry.sys.contentType.sys.id)} 217 | onSelect={props.onSelect} 218 | selectedEntry={props.selectedEntry} 219 | /> 220 |
221 | ))} 222 | 223 | ) : ( 224 |
225 | 226 | {props.query.length ? 'No entries found.' : 'Enter a search term above.'} 227 | 228 |
229 | )} 230 |
231 | ) 232 | } 233 | 234 | const EntryListItem = (props:EntryListItemProps) => { 235 | 236 | let entryTitle = 'Untitled' 237 | if (props.contentType && props.contentType.displayField && props.entry.fields[props.contentType.displayField]) { 238 | entryTitle = props.entry.fields[props.contentType.displayField] 239 | } 240 | 241 | return ( 242 | props.onSelect(props.entry)} 249 | selected={props.selectedEntry && (props.entry.sys.id === props.selectedEntry.sys.id)} 250 | /> 251 | ) 252 | } 253 | 254 | export default EntryPickerDialog 255 | -------------------------------------------------------------------------------- /src/components/Dialog/EntryPickerStyles.ts: -------------------------------------------------------------------------------- 1 | import tokens from '@contentful/forma-36-tokens' 2 | import { css } from 'emotion' 3 | 4 | export const modalRoot = css({ 5 | display: 'flex', 6 | flexDirection: 'column', 7 | height: 700, 8 | }) 9 | 10 | const focus = { 11 | outline: 'none', 12 | borderColor: tokens.colorPrimary, 13 | boxShadow: tokens.glowPrimary, 14 | height: 'auto', 15 | overflow: 'visible', 16 | } 17 | 18 | export const modal = { 19 | content: css({ 20 | height: '100vh', 21 | display: 'flex', 22 | flexDirection: 'column', 23 | }) 24 | } 25 | 26 | export const topBar = { 27 | root: css({ 28 | display: 'flex', 29 | justifyContent: 'space-between', 30 | marginBottom: tokens.spacingS, 31 | }) 32 | } 33 | 34 | export const search = { 35 | root: css({ 36 | marginBottom: tokens.spacingM, 37 | position: 'relative', 38 | zIndex: 1, 39 | }), 40 | wrapper: css({ 41 | height: '40px', 42 | width: '100%', 43 | position: 'relative', 44 | }), 45 | inputWrapper: css({ 46 | paddingLeft: '3px', 47 | display: 'flex', 48 | background: tokens.colorWhite, 49 | border: `1px solid ${tokens.colorElementMid}`, 50 | boxShadow: tokens.insetBoxShadowDefault, 51 | borderRadius: tokens.borderRadiusMedium, 52 | height: '38px', 53 | overflow: 'hidden', 54 | '&:focus-within, &:focus': focus, 55 | }), 56 | focused: css(focus), 57 | input: css({ 58 | flex: '1 1 auto', 59 | width: 'auto', 60 | height: '30px', 61 | paddingLeft: tokens.spacingXs, 62 | '& > input': { 63 | padding: 0, 64 | border: 'none !important', 65 | boxShadow: 'none !important', 66 | }, 67 | }), 68 | pillsInput: css({ 69 | display: 'flex', 70 | alignItems: 'center', 71 | flex: '1 1 auto', 72 | flexWrap: 'wrap', 73 | }), 74 | hidden: css({ 75 | visibility: 'hidden', 76 | }), 77 | spinner: css({ 78 | marginTop: tokens.spacingXs, 79 | marginRight: tokens.spacingXs, 80 | }) 81 | } 82 | 83 | export const filterPill = { 84 | root: css({ 85 | transition: 'margin .1s ease-in-out', 86 | display: 'inline-flex', 87 | alignItems: 'flex-start', 88 | fontWeight: 600, 89 | height: '30px', 90 | marginTop: '3px', 91 | marginBottom: '3px', 92 | marginRight: '5px', 93 | borderRadius: tokens.borderRadiusMedium, 94 | backgroundColor: tokens.colorElementLight, 95 | ':hover, :active': { 96 | backgroundColor: tokens.colorElementMid, 97 | }, 98 | ":hover div[data-search-filter-role='operator']::before": { 99 | backgroundColor: tokens.colorElementMid, 100 | }, 101 | ':focus': { 102 | outline: 'none', 103 | boxShadow: `0 0 0 1px ${tokens.colorBlueMid}`, 104 | }, 105 | }), 106 | label: css({ 107 | lineHeight: '30px', 108 | color: tokens.colorTextMid, 109 | padding: '0 12px', 110 | borderRadius: `${tokens.borderRadiusMedium} 0 0 ${tokens.borderRadiusMedium}`, 111 | cursor: 'pointer', 112 | }), 113 | } 114 | 115 | 116 | export const selectValueInput = { 117 | selectContainer: css({ 118 | display: 'flex', 119 | alignItems: 'center', 120 | position: 'relative', 121 | height: '100%', 122 | }), 123 | root: css({ 124 | position: 'relative', 125 | display: 'inline', 126 | height: '100%', 127 | }), 128 | select: css({ 129 | border: 0, 130 | transition: 'width .1s ease-in-out', 131 | padding: '0 25px 0 12px', 132 | minWidth: '60px', 133 | maxWidth: '200px', 134 | color: tokens.colorWhite, 135 | zIndex: 10, 136 | fontSize: 'inherit', 137 | textOverflow: 'ellipsis', 138 | appearance: 'none', 139 | backgroundColor: tokens.colorBlueMid, 140 | height: '100%', 141 | borderRadius: `0 ${tokens.borderRadiusMedium} ${tokens.borderRadiusMedium} 0`, 142 | fontFamily: tokens.fontStackPrimary, 143 | fontWeight: tokens.fontWeightMedium, 144 | }), 145 | option: css({ 146 | color: tokens.colorTextDark, 147 | backgroundColor: tokens.colorWhite, 148 | }), 149 | caret: css({ 150 | position: 'absolute', 151 | top: '5px', 152 | right: '5px', 153 | pointerEvents: 'none', 154 | }), 155 | spinner: css({ 156 | position: 'absolute', 157 | right: '5px', 158 | pointerEvents: 'none', 159 | }), 160 | } 161 | 162 | export const entryList = { 163 | root: css({ 164 | paddingTop: tokens.spacingS, 165 | marginTop: `-${tokens.spacingS}`, 166 | overflow: 'auto', 167 | flexGrow: 1, 168 | }), 169 | empty: css({ 170 | display: 'flex', 171 | justifyContent: 'center', 172 | alignItems: 'center', 173 | height: '100%', 174 | color: tokens.colorTextLightest, 175 | textOverflow: 'ellipsis', 176 | }), 177 | entryItem: css({ 178 | display: 'block', 179 | marginTop: tokens.spacingS, 180 | paddingLeft: tokens.spacing2Xs, 181 | }), 182 | assetItem: css({ 183 | display: 'inline-flex', 184 | marginTop: tokens.spacingS, 185 | marginRight: tokens.spacingS, 186 | }), 187 | loadingMore: css({ 188 | marginTop: tokens.spacingS, 189 | }), 190 | }; 191 | -------------------------------------------------------------------------------- /src/components/Dialog/SpaceConfigurationDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { DialogExtensionSDK } from '@contentful/app-sdk' 4 | import { TextField, Form, Modal, Button } from '@contentful/forma-36-react-components'; 5 | import { createClient } from 'contentful' 6 | 7 | import { SpaceConfiguration } from '../../Types' 8 | 9 | interface ValidInput { 10 | [id: string]: boolean; 11 | token: boolean; 12 | } 13 | 14 | interface SpaceConfigMessages { 15 | [id:string]: string; 16 | token: string; 17 | } 18 | 19 | interface SpaceConfigurationProps { 20 | sdk: DialogExtensionSDK; 21 | spaceConfig?: SpaceConfiguration; 22 | } 23 | 24 | interface SpaceConfigFieldProps { 25 | id: string; 26 | spaceConfig: SpaceConfiguration; 27 | validations: ValidInput; 28 | validationMessages: SpaceConfigMessages; 29 | onChange: (e:any) => void; 30 | onFocus: (e:any) => void; 31 | onBlur: (e:any) => void; 32 | } 33 | 34 | const SpaceConfigField = (props: SpaceConfigFieldProps) => { 35 | const fieldId = props.id 36 | 37 | let validationMessage = props.validationMessages[fieldId] ? props.validationMessages[fieldId] : 'This field is required' 38 | 39 | let labels:SpaceConfigMessages = { 40 | id: 'Space ID', 41 | token: 'CDA Token', 42 | } 43 | 44 | let help:SpaceConfigMessages = { 45 | id: 'Enter your Space ID', 46 | token: 'Enter a valid Content Delivery API Token for the selected space.' 47 | } 48 | 49 | 50 | return ( 51 | 63 | ); 64 | } 65 | 66 | 67 | const SpaceConfigurationDialog = (props: SpaceConfigurationProps) => { 68 | const [spaceConfig, setSpaceConfig] = useState(props.spaceConfig ? props.spaceConfig : {name: '', id: '', token: ''}) 69 | 70 | const [validInput, setValidInput] = useState({id: true, token: true}) 71 | const [validationMessages, setValidationMessages] = useState({id: '', token: ''}) 72 | 73 | const onFieldFocus = (id:string) => { 74 | setValidInput({...validInput, [id]: true}) 75 | } 76 | 77 | const onFieldBlur = (id:string, value:string) => { 78 | if (!value.length) { 79 | setValidInput({...validInput, [id]: false}) 80 | 81 | if (validationMessages[id]) { 82 | setValidationMessages({...validationMessages, [id]: ''}) 83 | } 84 | } 85 | } 86 | 87 | const onFieldChange = (id:string, value:string) => { 88 | setSpaceConfig({...spaceConfig, [id]: value}) 89 | } 90 | 91 | const onClose = () => { 92 | props.sdk.close() 93 | } 94 | 95 | const onSave = () => { 96 | if (validInput.id && validInput.token) { 97 | let client = createClient({ 98 | space: spaceConfig.id, 99 | accessToken: spaceConfig.token, 100 | }) 101 | 102 | client.getSpace() 103 | .then((space) => { 104 | props.sdk.close({...spaceConfig, ...{name: space.name}}) 105 | }) 106 | .catch(e => { 107 | props.sdk.notifier.error(e.message) 108 | let field = e.sys.id === "AccessTokenInvalid" ? 'token' : 'id' 109 | setValidInput({...validInput, ...{[field]: false}}) 110 | setValidationMessages({...validationMessages, ...{[field]: e.message}}) 111 | }) 112 | } 113 | } 114 | 115 | return ( 116 |
117 | 118 | {['id', 'token'].map((key:string) => ( 119 | ) => onFieldChange(key, e.currentTarget.value)} 121 | onFocus={() => onFieldFocus(key)} 122 | onBlur={(e:React.FocusEvent) => onFieldBlur(key, e.currentTarget.value)} 123 | key={key} 124 | id={key} 125 | spaceConfig={spaceConfig} 126 | validations={validInput} 127 | validationMessages={validationMessages} 128 | /> 129 | ))} 130 | 131 | 132 | 133 | 134 | 135 |
136 | ); 137 | }; 138 | 139 | export default SpaceConfigurationDialog; 140 | -------------------------------------------------------------------------------- /src/components/Dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { DialogExtensionSDK } from '@contentful/app-sdk' 3 | 4 | import { SpaceConfiguration } from '../../Types' 5 | 6 | import SpaceConfigurationDialog from './SpaceConfigurationDialog' 7 | import EntryPickerDialog from './EntryPickerDialog' 8 | 9 | interface DialogProps { 10 | sdk: DialogExtensionSDK; 11 | } 12 | 13 | interface DialogParameters { 14 | dialog: string; 15 | props: { 16 | spaceConfig?: SpaceConfiguration 17 | } 18 | } 19 | 20 | const Dialog = (props: DialogProps) => { 21 | 22 | useEffect(() => { 23 | props.sdk.window.startAutoResizer() 24 | return ( 25 | props.sdk.window.stopAutoResizer() 26 | ) 27 | }, [props.sdk.window]) 28 | 29 | let params = props.sdk.parameters.invocation as DialogParameters 30 | 31 | if (params) { 32 | if (params.dialog === 'SpaceConfiguration') { 33 | return 34 | } 35 | else if (params.dialog === 'EntryPicker' && params.props.spaceConfig) { 36 | return 37 | } 38 | } 39 | 40 | return null 41 | } 42 | 43 | export default Dialog 44 | -------------------------------------------------------------------------------- /src/components/EntryEditor.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import EntryEditor from './EntryEditor'; 3 | import { render } from '@testing-library/react'; 4 | 5 | describe('Entry component', () => { 6 | it('Component text exists', () => { 7 | const { getByText } = render(); 8 | 9 | expect(getByText('Hello Entry Editor Component')).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/EntryEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Paragraph } from '@contentful/forma-36-react-components'; 3 | import { EditorExtensionSDK } from '@contentful/app-sdk'; 4 | 5 | interface EditorProps { 6 | sdk: EditorExtensionSDK; 7 | } 8 | 9 | const Entry = (props: EditorProps) => { 10 | return Hello Entry Editor Component; 11 | }; 12 | 13 | export default Entry; 14 | -------------------------------------------------------------------------------- /src/components/Field.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Field from './Field'; 3 | import { render } from '@testing-library/react'; 4 | 5 | describe('Field component', () => { 6 | it('Component text exists', () => { 7 | const { getByText } = render(); 8 | 9 | expect(getByText('Hello Entry Field Component')).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/Field/CrossSpaceField/AdvisoryNote.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import { Note } from '@contentful/forma-36-react-components'; 4 | import tokens from '@contentful/forma-36-tokens'; 5 | 6 | import { css } from 'emotion'; 7 | 8 | const styles = { 9 | note: css({ 10 | marginBottom: tokens.spacingM 11 | }) 12 | } 13 | 14 | const AdvisoryNote = () => { 15 | const [showNote, setShowNote] = useState(true) 16 | 17 | return ( 18 | <> 19 | {showNote && 20 | setShowNote(false)} 24 | className={styles.note} 25 | > 26 | Search for and select entries from a space other than the one you are currently using. You may see entries in this list that you don't have access to manage within Contentful. 27 | 28 | } 29 | 30 | ) 31 | } 32 | 33 | export default AdvisoryNote 34 | -------------------------------------------------------------------------------- /src/components/Field/CrossSpaceField/CrossSpaceEntryActions.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import { Entry } from 'contentful' 4 | import { FieldExtensionSDK } from '@contentful/app-sdk'; 5 | import { Button, Dropdown, DropdownList, DropdownListItem } from '@contentful/forma-36-react-components'; 6 | import { SpaceConfiguration } from '../../../Types' 7 | 8 | import { css } from 'emotion'; 9 | 10 | 11 | interface CrossSpaceEntryActionsProps { 12 | spaceConfigs: SpaceConfiguration[]; 13 | sdk: FieldExtensionSDK; 14 | } 15 | 16 | const CrossSpaceEntryActions = (props:CrossSpaceEntryActionsProps) => { 17 | const [isOpen, setIsOpen] = useState(false) 18 | 19 | const calculateHeight = () => { 20 | let height = 40 + (37 * props.spaceConfigs.length) 21 | return isOpen ? height : 0 22 | } 23 | 24 | const styles = { 25 | wrapper: css({ 26 | display: 'flex', 27 | border: '1px dashed #b4c3ca', 28 | borderRadius: '6px', 29 | justifyContent: 'center', 30 | padding: '2rem' 31 | }), 32 | open: css({ 33 | height: calculateHeight() 34 | }) 35 | } 36 | 37 | 38 | const onSpaceSelect = (spaceId:string) => { 39 | let spaceConfig = props.spaceConfigs.find((config) => config.id === spaceId) 40 | 41 | if (spaceConfig) { 42 | props.sdk.dialogs.openCurrentApp({ 43 | position: 'center', 44 | // title: 'Insert cross-space entry', 45 | parameters: { 46 | dialog: 'EntryPicker', 47 | props: {spaceConfig: spaceConfig} 48 | }, 49 | width: 800, 50 | shouldCloseOnOverlayClick: true, 51 | shouldCloseOnEscapePress: true, 52 | }) 53 | .then((response:Entry|undefined) => { 54 | if (response !== undefined) { 55 | props.sdk.field.setValue({ 56 | sys: { 57 | type: "Link", 58 | linkType: "CrossSpaceEntry", 59 | id: response.sys.id, 60 | space: response.sys.space, 61 | } 62 | }) 63 | .catch((err) => { 64 | props.sdk.notifier.error(err) 65 | }) 66 | } 67 | }) 68 | } 69 | } 70 | 71 | const onButtonClick = () => { 72 | setIsOpen(!isOpen) 73 | } 74 | 75 | return ( 76 | <> 77 |
78 | setIsOpen(false)} 82 | toggleElement={ 83 | 91 | } 92 | > 93 | 94 | Select Space 95 | 96 | 97 | {props.spaceConfigs.map((config:any) => ( 98 | onSpaceSelect(config.id)} 101 | > 102 | {config.name} 103 | 104 | ))} 105 | 106 | 107 |
108 |
109 | 110 | ) 111 | } 112 | 113 | export default CrossSpaceEntryActions 114 | -------------------------------------------------------------------------------- /src/components/Field/CrossSpaceField/CrossSpaceReferenceEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | 3 | import { createClient, Entry, ContentType, LocaleCollection, Locale } from 'contentful' 4 | import { WrappedEntryCard, MissingEntityCard } from '@contentful/field-editor-reference' 5 | import { EntryCard } from '@contentful/forma-36-react-components'; 6 | import { FieldExtensionSDK } from '@contentful/app-sdk' 7 | 8 | import { SpaceConfiguration, CrossSpaceLink, CrossSpaceEntryData } from '../../../Types' 9 | import CrossSpaceEntryActions from './CrossSpaceEntryActions' 10 | 11 | interface CrossSpaceReferenceEditorProps { 12 | sdk: FieldExtensionSDK; 13 | spaceConfigs: SpaceConfiguration[]; 14 | value?: { 15 | sys: CrossSpaceLink 16 | }; 17 | } 18 | 19 | export const CrossSpaceReferenceEditor = (props:CrossSpaceReferenceEditorProps) => { 20 | const [crossSpaceEntryData, setCrossSpaceEntryData] = useState() 21 | const [loading, setLoading] = useState(false) 22 | 23 | const getCrossSpaceEntryData = React.useCallback(async (link:CrossSpaceLink, spaceConfigs:SpaceConfiguration[]) => { 24 | let entryData:any = {} 25 | 26 | let spaceConfig = spaceConfigs.find((config:SpaceConfiguration) => link.space.sys.id === config.id) 27 | 28 | if (spaceConfig) { 29 | let client = createClient({ 30 | space: spaceConfig.id, 31 | accessToken: spaceConfig.token, 32 | }) 33 | 34 | let locales:LocaleCollection = await client.getLocales() 35 | if (locales && locales.total) { 36 | entryData.defaultLocale = locales.items.find((locale:Locale) => locale.default); 37 | } 38 | 39 | let entry:Entry = await client.getEntry(link.id, {locale: '*'}) 40 | if (entry) { 41 | 42 | //Add fake published data so that the correct status is shown. 43 | entryData.entry = { 44 | ...entry, 45 | sys: { 46 | ...entry.sys, 47 | version: entry.sys.revision, 48 | publishedVersion: entry.sys.revision 49 | } 50 | } 51 | 52 | let contentType:ContentType = await client.getContentType(entry.sys.contentType.sys.id) 53 | if (contentType) { 54 | entryData.contentType = contentType 55 | } 56 | } 57 | } 58 | 59 | return entryData 60 | }, []) 61 | 62 | const onRemoveEntry = () => { 63 | props.sdk.field.removeValue() 64 | } 65 | 66 | useEffect(() => { 67 | if (props.value) { 68 | setLoading(true) 69 | 70 | getCrossSpaceEntryData(props.value.sys, props.spaceConfigs) 71 | .then((data:CrossSpaceEntryData) => { 72 | setCrossSpaceEntryData(data) 73 | setLoading(false) 74 | }) 75 | } 76 | }, [props.value, props.spaceConfigs, getCrossSpaceEntryData]) 77 | 78 | let entryCardProps:any = { 79 | getEntityScheduledActions: async () => [], 80 | getAsset: async () => {}, 81 | size: 'auto' as 'auto', 82 | isDisabled: false, 83 | localeCode: '', 84 | defaultLocaleCode: '', 85 | contentType: null, 86 | hasCardEditActions: true, 87 | isClickable: false, 88 | } 89 | 90 | const CrossSpaceEntryCard = (props:any) => { 91 | if (loading) { 92 | return 93 | } 94 | 95 | if (props.crossSpaceEntryData) { 96 | return 103 | } 104 | 105 | return 106 | } 107 | 108 | return ( 109 | <> 110 | {props.value ? 111 | 112 | : 113 | 114 | } 115 | 116 | 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /src/components/Field/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import { FieldExtensionSDK } from '@contentful/app-sdk'; 4 | 5 | import { SpaceConfiguration } from 'Types' 6 | 7 | import { CrossSpaceReferenceEditor } from './CrossSpaceField/CrossSpaceReferenceEditor' 8 | import AdvisoryNote from './CrossSpaceField/AdvisoryNote' 9 | 10 | interface FieldProps { 11 | sdk: FieldExtensionSDK; 12 | } 13 | 14 | const Field = (props: FieldProps) => { 15 | console.log(props.sdk.entry) 16 | const { spaceConfigs } = props.sdk.parameters.installation as {spaceConfigs:SpaceConfiguration[]} 17 | const [value, setValue] = useState(props.sdk.field.getValue()) 18 | 19 | // Fetch and update field value as needed. 20 | useEffect(() => { 21 | const detachValueChangeHandler = props.sdk.field.onValueChanged( async (value:any) => { 22 | setValue(value) 23 | }) 24 | 25 | return () => { 26 | return detachValueChangeHandler() 27 | } 28 | }, [props.sdk.field]) 29 | 30 | 31 | // Start AutoResizer at initialization of component. 32 | useEffect(() => { 33 | props.sdk.window.startAutoResizer(); 34 | return () => { 35 | return props.sdk.window.stopAutoResizer() 36 | } 37 | }, [props.sdk.window]) 38 | 39 | const CrossSpaceField = () => { 40 | 41 | if (spaceConfigs.length) { 42 | return ( 43 | <> 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | return null 51 | } 52 | 53 | 54 | return 55 | }; 56 | 57 | export default Field; 58 | -------------------------------------------------------------------------------- /src/components/Page.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Page from './Page'; 3 | import { render } from '@testing-library/react'; 4 | 5 | describe('Page component', () => { 6 | it('Component text exists', () => { 7 | const { getByText } = render(); 8 | 9 | expect(getByText('Hello Page Component')).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Paragraph } from '@contentful/forma-36-react-components'; 3 | import { PageExtensionSDK } from '@contentful/app-sdk'; 4 | 5 | interface PageProps { 6 | sdk: PageExtensionSDK; 7 | } 8 | 9 | const Page = (props: PageProps) => { 10 | return Hello Page Component; 11 | }; 12 | 13 | export default Page; 14 | -------------------------------------------------------------------------------- /src/components/Sidebar.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Sidebar from './Sidebar'; 3 | import { render } from '@testing-library/react'; 4 | 5 | describe('Sidebar component', () => { 6 | it('Component text exists', () => { 7 | const { getByText } = render(); 8 | 9 | expect(getByText('Hello Sidebar Component')).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Paragraph } from '@contentful/forma-36-react-components'; 3 | import { SidebarExtensionSDK } from '@contentful/app-sdk'; 4 | 5 | interface SidebarProps { 6 | sdk: SidebarExtensionSDK; 7 | } 8 | 9 | const Sidebar = (props: SidebarProps) => { 10 | return Hello Sidebar Component; 11 | }; 12 | 13 | export default Sidebar; 14 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div { 4 | margin: 0; 5 | padding: 0; 6 | border: 0; 7 | font-size: 100%; 8 | font: inherit; 9 | vertical-align: baseline; 10 | } 11 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import { 5 | AppExtensionSDK, 6 | FieldExtensionSDK, 7 | SidebarExtensionSDK, 8 | DialogExtensionSDK, 9 | EditorExtensionSDK, 10 | PageExtensionSDK, 11 | BaseExtensionSDK, 12 | init, 13 | locations 14 | } from '@contentful/app-sdk'; 15 | import '@contentful/forma-36-react-components/dist/styles.css'; 16 | import '@contentful/forma-36-fcss/dist/styles.css'; 17 | import './index.css'; 18 | 19 | import ConfigScreen from './components/ConfigScreen'; 20 | import EntryEditor from './components/EntryEditor'; 21 | import Page from './components/Page'; 22 | import Sidebar from './components/Sidebar'; 23 | import Field from './components/Field'; 24 | import Dialog from './components/Dialog'; 25 | 26 | init((sdk: BaseExtensionSDK) => { 27 | const root = document.getElementById('root'); 28 | 29 | // All possible locations for your app 30 | // Feel free to remove unused locations 31 | // Dont forget to delete the file too :) 32 | const ComponentLocationSettings = [ 33 | { 34 | location: locations.LOCATION_APP_CONFIG, 35 | component: 36 | }, 37 | { 38 | location: locations.LOCATION_ENTRY_FIELD, 39 | component: 40 | }, 41 | { 42 | location: locations.LOCATION_ENTRY_EDITOR, 43 | component: 44 | }, 45 | { 46 | location: locations.LOCATION_DIALOG, 47 | component: 48 | }, 49 | { 50 | location: locations.LOCATION_ENTRY_SIDEBAR, 51 | component: 52 | }, 53 | { 54 | location: locations.LOCATION_PAGE, 55 | component: 56 | } 57 | ]; 58 | 59 | // Select a component depending on a location in which the app is rendered. 60 | // 61 | // NB: Location "app-config" is auto-included in the list as most apps need it 62 | // You can remove it (and on the app definition also) in case the app 63 | // doesn't require it 64 | ComponentLocationSettings.forEach(componentLocationSetting => { 65 | if (sdk.location.is(componentLocationSetting.location)) { 66 | render(componentLocationSetting.component, root); 67 | } 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------