diff --git a/.changeset/blue-ways-work.md b/.changeset/blue-ways-work.md new file mode 100644 index 00000000000..c5f9b61887a --- /dev/null +++ b/.changeset/blue-ways-work.md @@ -0,0 +1,28 @@ +--- +'@keystonejs/app-admin-ui': minor +'@keystonejs/field-content': minor +'@keystonejs/fields-markdown': minor +'@keystonejs/fields-wysiwyg-tinymce': minor +'@keystonejs/fields': minor +'@keystonejs/cypress-project-access-control': patch +--- + +* Added `isReadOnly` option on field's `adminConfig`. Fields with this option set will be excluded from the `create` form, and set as disabled in the `update` form in the Admin UI. +* Updated the item detail page to include fields with access `{ update: false }` in a disabled state, rather than excluded the form. + +Example: + +```js +keystone.createList('Todo', { + fields: { + name: { type: Text, isRequired: true }, + someReadOnlyField: { + type: Text, + adminConfig: { + isReadOnly: true, + }, + defaultValue: 'Some default value', + }, + }, +}); +``` diff --git a/packages/app-admin-ui/client/components/CreateItemModal.js b/packages/app-admin-ui/client/components/CreateItemModal.js index b034affa450..cba8e0e7875 100644 --- a/packages/app-admin-ui/client/components/CreateItemModal.js +++ b/packages/app-admin-ui/client/components/CreateItemModal.js @@ -192,6 +192,7 @@ const CreateItemModal = ({ prefillData = {}, onClose, onCreate }) => { {() => { const creatable = list.fields .filter(({ isPrimaryKey }) => !isPrimaryKey) + .filter(({ isReadOnly }) => !isReadOnly) .filter(({ maybeAccess }) => !!maybeAccess.create); captureSuspensePromises(creatable.map(field => () => field.initFieldView())); diff --git a/packages/app-admin-ui/client/pages/Item/index.js b/packages/app-admin-ui/client/pages/Item/index.js index 01e219d6853..5dae6e73366 100644 --- a/packages/app-admin-ui/client/pages/Item/index.js +++ b/packages/app-admin-ui/client/pages/Item/index.js @@ -44,6 +44,8 @@ const Form = props =>
mapKeys(fieldsObject, field => field.serialize(item)); +const checkIsReadOnly = ({ maybeAccess, isReadOnly }) => !maybeAccess.update || !!isReadOnly; + // Memoizing allows us to reduce the calls to `.serialize` when data hasn't // changed. const getInitialValues = memoizeOne(getValues); @@ -52,9 +54,7 @@ const getCurrentValues = memoizeOne(getValues); const deserializeItem = memoizeOne((list, data) => list.deserializeItemData(data)); const getRenderableFields = memoizeOne(list => - list.fields - .filter(({ isPrimaryKey }) => !isPrimaryKey) - .filter(({ maybeAccess, config }) => !!maybeAccess.update || !!config.isReadOnly) + list.fields.filter(({ isPrimaryKey }) => !isPrimaryKey) ); const ItemDetails = ({ list, item: initialData, itemErrors, onUpdate }) => { @@ -235,6 +235,7 @@ const ItemDetails = ({ list, item: initialData, itemErrors, onUpdate }) => { {() => { const [Field] = field.readViews([field.views.Field]); + const isReadOnly = checkIsReadOnly(field); // eslint-disable-next-line react-hooks/rules-of-hooks const onChange = useCallback( value => { @@ -265,6 +266,7 @@ const ItemDetails = ({ list, item: initialData, itemErrors, onUpdate }) => { field={field} list={list} item={item} + isReadOnly={isReadOnly} errors={[ ...(itemErrors[field.path] ? [itemErrors[field.path]] : []), ...(validationErrors[field.path] || []), @@ -287,6 +289,7 @@ const ItemDetails = ({ list, item: initialData, itemErrors, onUpdate }) => { validationWarnings[field.path], initialData[field.path], onChange, + isReadOnly, ] ); }} diff --git a/packages/field-content/src/views/Field.js b/packages/field-content/src/views/Field.js index 52f90961f34..196da8fd331 100644 --- a/packages/field-content/src/views/Field.js +++ b/packages/field-content/src/views/Field.js @@ -24,7 +24,7 @@ class ErrorBoundary extends Component { } } -let ContentField = ({ field, value, onChange, autoFocus, errors }) => { +let ContentField = ({ field, value, onChange, autoFocus, errors, isReadOnly }) => { const htmlID = `ks-content-editor-${field.path}`; return ( @@ -62,6 +62,7 @@ let ContentField = ({ field, value, onChange, autoFocus, errors }) => { padding: '16px 32px', minHeight: 200, }} + isReadOnly={isReadOnly} /> )} diff --git a/packages/field-content/src/views/editor/index.js b/packages/field-content/src/views/editor/index.js index b9fb07b0b70..83bb08bb873 100755 --- a/packages/field-content/src/views/editor/index.js +++ b/packages/field-content/src/views/editor/index.js @@ -32,7 +32,7 @@ function getSchema(blocks) { return schema; } -function Stories({ value: editorState, onChange, blocks, className, id }) { +function Stories({ value: editorState, onChange, blocks, className, id, isReadOnly }) { let schema = useMemo(() => { return getSchema(blocks); }, [blocks]); @@ -75,6 +75,7 @@ function Stories({ value: editorState, onChange, blocks, className, id }) { onChange={({ value }) => { onChange(value); }} + readOnly={isReadOnly} /> diff --git a/packages/fields-markdown/src/views/Field.js b/packages/fields-markdown/src/views/Field.js index d22884785c1..5e7686fe72e 100644 --- a/packages/fields-markdown/src/views/Field.js +++ b/packages/fields-markdown/src/views/Field.js @@ -62,7 +62,7 @@ const IconToolbarButton = ({ isActive, label, icon, tooltipPlacement = 'top', .. ); }; -export default function MarkdownField({ field, errors, value, onChange }) { +export default function MarkdownField({ field, errors, value, onChange, isReadOnly }) { const htmlID = `ks-input-${field.path}`; const accessError = errors.find( error => error instanceof Error && error.name === 'AccessDeniedError' @@ -81,7 +81,15 @@ export default function MarkdownField({ field, errors, value, onChange }) { }} > {tools.map(({ action, label, icon: Icon }) => { - return } onClick={action} label={label} />; + return ( + } + onClick={action} + label={label} + disabled={isReadOnly} + /> + ); })} ); @@ -122,6 +130,7 @@ export default function MarkdownField({ field, errors, value, onChange }) { tabSize: '2', lineWrapping: true, addModeClass: true, + readOnly: isReadOnly, }} editorDidMount={editor => { setTools(getTools(editor)); diff --git a/packages/fields-wysiwyg-tinymce/src/views/Field.js b/packages/fields-wysiwyg-tinymce/src/views/Field.js index 070bf721b73..756b9c51bc0 100644 --- a/packages/fields-wysiwyg-tinymce/src/views/Field.js +++ b/packages/fields-wysiwyg-tinymce/src/views/Field.js @@ -37,7 +37,7 @@ const GlobalStyles = () => ( /> ); -const WysiwygField = ({ onChange, autoFocus, field, errors, value: serverValue }) => { +const WysiwygField = ({ onChange, autoFocus, field, errors, value: serverValue, isReadOnly }) => { const handleChange = value => { if (typeof value === 'string') { onChange(value); @@ -63,6 +63,7 @@ const WysiwygField = ({ onChange, autoFocus, field, errors, value: serverValue } init={{ ...defaultOptions, auto_focus: autoFocus, ...overrideOptions }} onEditorChange={handleChange} value={value} + isDisabled={isReadOnly} /> diff --git a/packages/fields/README.md b/packages/fields/README.md index 40a7f8396cf..f3ab4bb20df 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -81,6 +81,28 @@ A description of the field used in the AdminUI. A description of the field used used in the GraphQL schema. +### `adminConfig` + +Additional field configs affecting field rendering or display in `admin-ui`. + +#### `adminConfig.isReadOnly` + +Fields with `isReadOnly` set to `true` will be disabled preventing users from modifying them in the Admin UI. This does not affect access control and fields can still be updated via GraphQL. + +```javascript +keystone.createList('Post', { + fields: { + title: { type: Text }, + slug: { + type: Slug, + adminConfig: { + isReadOnly: true, //slug can be created automatically and you may want to show this as read only + }, + }, + }, +}); +``` + ### `defaultValue` Sets the value when no data is provided. diff --git a/packages/fields/src/Controller.js b/packages/fields/src/Controller.js index 86910ab5a2c..08b96d49a47 100644 --- a/packages/fields/src/Controller.js +++ b/packages/fields/src/Controller.js @@ -10,6 +10,7 @@ export default class FieldController { isOrderable, isPrimaryKey, isRequired, + isReadOnly, adminDoc, defaultValue, ...config @@ -25,6 +26,7 @@ export default class FieldController { this.isOrderable = isOrderable; this.isPrimaryKey = isPrimaryKey; this.isRequired = isRequired; + this.isReadOnly = isReadOnly; this.adminDoc = adminDoc; this.readViews = readViews; this.preloadViews = preloadViews; diff --git a/packages/fields/src/types/CalendarDay/views/Field.js b/packages/fields/src/types/CalendarDay/views/Field.js index 75338d7b654..3b93dd74fe0 100644 --- a/packages/fields/src/types/CalendarDay/views/Field.js +++ b/packages/fields/src/types/CalendarDay/views/Field.js @@ -6,7 +6,7 @@ import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch- import { TextDayPicker } from '@arch-ui/day-picker'; import { Alert } from '@arch-ui/alert'; -const CalendarDayField = ({ autoFocus, field, value, errors, onChange }) => { +const CalendarDayField = ({ autoFocus, field, value, errors, onChange, isReadOnly }) => { const htmlID = `ks-daypicker-${field.path}`; return ( @@ -20,6 +20,7 @@ const CalendarDayField = ({ autoFocus, field, value, errors, onChange }) => { date={value} format={field.config.format} onChange={onChange} + disabled={isReadOnly} /> diff --git a/packages/fields/src/types/Checkbox/views/Field.js b/packages/fields/src/types/Checkbox/views/Field.js index b3376294d58..9f3f1af4682 100644 --- a/packages/fields/src/types/Checkbox/views/Field.js +++ b/packages/fields/src/types/Checkbox/views/Field.js @@ -6,7 +6,7 @@ import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch- import { CheckboxPrimitive } from '@arch-ui/controls'; -const CheckboxField = ({ onChange, autoFocus, field, value, errors }) => { +const CheckboxField = ({ onChange, autoFocus, field, value, errors, isReadOnly }) => { const handleChange = event => { onChange(event.target.checked); }; @@ -23,6 +23,7 @@ const CheckboxField = ({ onChange, autoFocus, field, value, errors }) => { checked={checked} onChange={handleChange} id={htmlID} + isDisabled={isReadOnly} /> { - const { uploadButtonLabel } = this.props; + const { uploadButtonLabel, isReadOnly } = this.props; const { changeStatus, isLoading } = this.state; return ( - + {uploadButtonLabel({ status: changeStatus })} ); }; renderCancelButton = () => { - const { cancelButtonLabel } = this.props; + const { cancelButtonLabel, isReadOnly } = this.props; const { changeStatus } = this.state; // possible states; no case for 'empty' as cancel is not rendered @@ -214,14 +219,14 @@ export default class CloudinaryImageField extends Component { } return ( - ); }; render() { - const { autoFocus, field, statusMessage, errors } = this.props; + const { autoFocus, field, statusMessage, errors, isReadOnly } = this.props; const { changeStatus, errorMessage } = this.state; const { file } = this.getFile(); @@ -269,6 +274,7 @@ export default class CloudinaryImageField extends Component { name={field.path} onChange={this.onChange} type="file" + disabled={isReadOnly} /> diff --git a/packages/fields/src/types/Color/views/Field.js b/packages/fields/src/types/Color/views/Field.js index 0fa0725a5f6..738ae38660a 100644 --- a/packages/fields/src/types/Color/views/Field.js +++ b/packages/fields/src/types/Color/views/Field.js @@ -6,7 +6,7 @@ import Popout from '@arch-ui/popout'; import { Button } from '@arch-ui/button'; import SketchPicker from 'react-color/lib/Sketch'; -const ColorField = ({ field, value: serverValue, errors, onChange }) => { +const ColorField = ({ field, value: serverValue, errors, onChange, isReadOnly }) => { const value = serverValue || ''; const htmlID = `ks-input-${field.path}`; @@ -24,7 +24,7 @@ const ColorField = ({ field, value: serverValue, errors, onChange }) => { }, [value]); const target = props => ( - ); }; render() { - const { autoFocus, field, statusMessage, errors } = this.props; + const { autoFocus, field, statusMessage, errors, isReadOnly } = this.props; const { changeStatus, errorMessage } = this.state; const { file } = this.getFile(); @@ -260,6 +265,7 @@ export default class FileField extends Component { name={field.path} onChange={this.onChange} type="file" + disabled={isReadOnly} /> diff --git a/packages/fields/src/types/Float/views/Field.js b/packages/fields/src/types/Float/views/Field.js index d489a12938c..bf6276b280e 100644 --- a/packages/fields/src/types/Float/views/Field.js +++ b/packages/fields/src/types/Float/views/Field.js @@ -3,7 +3,7 @@ import React from 'react'; import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch-ui/fields'; import { Input } from '@arch-ui/input'; -const FloatField = ({ onChange, autoFocus, field, value, errors }) => { +const FloatField = ({ onChange, autoFocus, field, value, errors, isReadOnly }) => { const handleChange = event => { const value = event.target.value; // Similar implementation as per old Keystone version @@ -38,6 +38,7 @@ const FloatField = ({ onChange, autoFocus, field, value, errors }) => { value={valueToString(value)} onChange={handleChange} id={htmlID} + disabled={isReadOnly} /> diff --git a/packages/fields/src/types/Integer/views/Field.js b/packages/fields/src/types/Integer/views/Field.js index d3880876c80..db60d211a08 100644 --- a/packages/fields/src/types/Integer/views/Field.js +++ b/packages/fields/src/types/Integer/views/Field.js @@ -3,7 +3,7 @@ import React from 'react'; import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch-ui/fields'; import { Input } from '@arch-ui/input'; -const IntegerField = ({ onChange, autoFocus, field, value, errors }) => { +const IntegerField = ({ onChange, autoFocus, field, value, errors, isReadOnly }) => { const handleChange = event => { const value = event.target.value; onChange(value.replace(/\D/g, '')); @@ -35,6 +35,7 @@ const IntegerField = ({ onChange, autoFocus, field, value, errors }) => { value={valueToString(value)} onChange={handleChange} id={htmlID} + disabled={isReadOnly} /> diff --git a/packages/fields/src/types/Location/views/Field.js b/packages/fields/src/types/Location/views/Field.js index 14c16014135..ed6b2a51062 100644 --- a/packages/fields/src/types/Location/views/Field.js +++ b/packages/fields/src/types/Location/views/Field.js @@ -6,7 +6,15 @@ import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch- import Select from '@arch-ui/select'; import { Map, Marker, GoogleApiWrapper } from 'google-maps-react'; -const LocationField = ({ field, value: serverValue, errors, onChange, google, renderContext }) => { +const LocationField = ({ + field, + value: serverValue, + errors, + onChange, + google, + renderContext, + isReadOnly, +}) => { const { googlePlaceID, formattedAddress, lat, lng } = serverValue || {}; const htmlID = `ks-input-${field.path}`; const autocompleteService = new google.maps.places.AutocompleteService(); @@ -89,6 +97,7 @@ const LocationField = ({ field, value: serverValue, errors, onChange, google, re inputId={htmlID} instanceId={htmlID} css={{ width: '100%' }} + isDisabled={isReadOnly} {...selectProps} /> {marker && ( diff --git a/packages/fields/src/types/OEmbed/views/Field.js b/packages/fields/src/types/OEmbed/views/Field.js index a7c880fba85..81dd7c557c5 100644 --- a/packages/fields/src/types/OEmbed/views/Field.js +++ b/packages/fields/src/types/OEmbed/views/Field.js @@ -40,7 +40,15 @@ const PlaceholderPreview = ({ originalUrl, fieldPath }) => ( /> ); -const OEmbedField = ({ onChange, autoFocus, field, value = null, savedValue = null, errors }) => { +const OEmbedField = ({ + onChange, + autoFocus, + field, + value = null, + savedValue = null, + errors, + isReadOnly, +}) => { const handleChange = event => { onChange({ originalUrl: event.target.value, @@ -67,6 +75,7 @@ const OEmbedField = ({ onChange, autoFocus, field, value = null, savedValue = nu placeholder={canRead ? undefined : error.message} onChange={handleChange} id={htmlID} + disabled={isReadOnly} /> {value && value.originalUrl && hasChanged && ( diff --git a/packages/fields/src/types/Password/views/Field.js b/packages/fields/src/types/Password/views/Field.js index 9998808cd8e..ba206c68894 100644 --- a/packages/fields/src/types/Password/views/Field.js +++ b/packages/fields/src/types/Password/views/Field.js @@ -18,6 +18,7 @@ const PasswordField = ({ item: { password_is_set } = {}, errors, warnings, + isReadOnly, }) => { const focusTarget = useRef(); @@ -79,6 +80,7 @@ const PasswordField = ({ placeholder="New Password" type={showInputValue ? 'text' : 'password'} value={inputPassword} + disabled={isReadOnly} /> ) : ( - )} diff --git a/packages/fields/src/types/Relationship/views/Field.js b/packages/fields/src/types/Relationship/views/Field.js index 74187bb5705..168215ba089 100644 --- a/packages/fields/src/types/Relationship/views/Field.js +++ b/packages/fields/src/types/Relationship/views/Field.js @@ -16,7 +16,7 @@ import { CreateItemModal, ListProvider, useList } from '@keystonejs/app-admin-ui const MAX_IDS_IN_FILTER = 100; -function SetAsCurrentUser({ listKey, value, onAddUser, many }) { +function SetAsCurrentUser({ listKey, value, onAddUser, many, isDisabled }) { const path = 'authenticated' + listKey; const { data } = useQuery(gql` @@ -46,6 +46,7 @@ function SetAsCurrentUser({ listKey, value, onAddUser, many }) { }} icon={PersonIcon} aria-label={label} + isDisabled={isDisabled} /> )} @@ -103,7 +104,7 @@ function LinkToRelatedItems({ field, value }) { ); } -function CreateAndAddItem({ field, item, onCreate }) { +function CreateAndAddItem({ field, item, onCreate, isDisabled }) { const { list, openCreateItemModal } = useList(); let relatedList = field.getRefList(); @@ -144,6 +145,7 @@ function CreateAndAddItem({ field, item, onCreate }) { aria-label={label} variant="ghost" css={{ marginLeft: gridSize }} + isDisabled={isDisabled} /> ); }} @@ -167,6 +169,7 @@ const RelationshipField = ({ onChange, item, list, + isReadOnly, }) => { const handleChange = option => { const { many } = field.config; @@ -198,6 +201,7 @@ const RelationshipField = ({ renderContext={renderContext} htmlID={htmlID} onChange={handleChange} + isDisabled={isReadOnly} /> @@ -208,6 +212,7 @@ const RelationshipField = ({ field={field} item={item} list={list} + isDisabled={isReadOnly} /> {authStrategy && ref === authStrategy.listKey && ( @@ -218,6 +223,7 @@ const RelationshipField = ({ }} value={value} listKey={authStrategy.listKey} + isDisabled={isReadOnly} /> )} diff --git a/packages/fields/src/types/Relationship/views/RelationshipSelect.js b/packages/fields/src/types/Relationship/views/RelationshipSelect.js index 66fcad6786e..b8223809fe6 100644 --- a/packages/fields/src/types/Relationship/views/RelationshipSelect.js +++ b/packages/fields/src/types/Relationship/views/RelationshipSelect.js @@ -39,6 +39,7 @@ const Relationship = forwardRef( setSearch, selectProps, fetchMore, + isDisabled, }, ref ) => { @@ -140,6 +141,7 @@ const Relationship = forwardRef( inputId={htmlID} innerRef={ref} menuPortalTarget={document.body} + isDisabled={isDisabled} {...selectProps} /> ); @@ -156,6 +158,7 @@ const RelationshipSelect = ({ onChange, isMulti, value, + isDisabled, }) => { const [search, setSearch] = useState(''); const refList = field.getRefList(); @@ -210,6 +213,7 @@ const RelationshipSelect = ({ selectProps, fetchMore, ref: innerRef, + isDisabled, }} /> ); diff --git a/packages/fields/src/types/Select/views/Field.js b/packages/fields/src/types/Select/views/Field.js index dbb8e6e4dee..e5339cba2f2 100644 --- a/packages/fields/src/types/Select/views/Field.js +++ b/packages/fields/src/types/Select/views/Field.js @@ -5,7 +5,15 @@ import { jsx } from '@emotion/core'; import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch-ui/fields'; import Select from '@arch-ui/select'; -const SelectField = ({ onChange, autoFocus, field, value: serverValue, renderContext, errors }) => { +const SelectField = ({ + onChange, + autoFocus, + field, + value: serverValue, + renderContext, + errors, + isReadOnly, +}) => { const handleChange = option => { onChange(option ? option.value : null); }; @@ -41,6 +49,7 @@ const SelectField = ({ onChange, autoFocus, field, value: serverValue, renderCon id={`react-select-${htmlID}`} inputId={htmlID} instanceId={htmlID} + isDisabled={isReadOnly} {...selectProps} /> diff --git a/packages/fields/src/types/Text/views/Field.js b/packages/fields/src/types/Text/views/Field.js index f0a82ba0e1a..9eb0a7e4125 100644 --- a/packages/fields/src/types/Text/views/Field.js +++ b/packages/fields/src/types/Text/views/Field.js @@ -5,7 +5,7 @@ import { jsx } from '@emotion/core'; import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch-ui/fields'; import { Input } from '@arch-ui/input'; -const TextField = ({ onChange, autoFocus, field, errors, value: serverValue }) => { +const TextField = ({ onChange, autoFocus, field, errors, value: serverValue, isReadOnly }) => { const handleChange = event => { onChange(event.target.value); }; @@ -32,6 +32,7 @@ const TextField = ({ onChange, autoFocus, field, errors, value: serverValue }) = onChange={handleChange} id={htmlID} isMultiline={isMultiline} + disabled={isReadOnly} /> diff --git a/packages/fields/src/types/Unsplash/views/Field.js b/packages/fields/src/types/Unsplash/views/Field.js index c46dab748fa..5e225b3570e 100644 --- a/packages/fields/src/types/Unsplash/views/Field.js +++ b/packages/fields/src/types/Unsplash/views/Field.js @@ -5,7 +5,7 @@ import { jsx } from '@emotion/core'; import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch-ui/fields'; import { Input } from '@arch-ui/input'; -const UnsplashField = ({ onChange, autoFocus, field, errors, value: serverValue }) => { +const UnsplashField = ({ onChange, autoFocus, field, errors, value: serverValue, isReadOnly }) => { const handleChange = event => { onChange(event.target.value); }; @@ -30,6 +30,7 @@ const UnsplashField = ({ onChange, autoFocus, field, errors, value: serverValue placeholder={canRead ? 'Unsplash Image ID' : error.message} onChange={handleChange} id={htmlID} + disabled={isReadOnly} /> diff --git a/packages/fields/src/types/Url/views/Field.js b/packages/fields/src/types/Url/views/Field.js index 30f165cfd71..d464dae7f2e 100644 --- a/packages/fields/src/types/Url/views/Field.js +++ b/packages/fields/src/types/Url/views/Field.js @@ -5,7 +5,7 @@ import { jsx } from '@emotion/core'; import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch-ui/fields'; import { Input } from '@arch-ui/input'; -const UrlField = ({ onChange, autoFocus, field, value: serverValue, errors }) => { +const UrlField = ({ onChange, autoFocus, field, value: serverValue, errors, isReadOnly }) => { const handleChange = event => { onChange(event.target.value); }; @@ -30,6 +30,7 @@ const UrlField = ({ onChange, autoFocus, field, value: serverValue, errors }) => placeholder={canRead ? undefined : error.message} onChange={handleChange} id={htmlID} + disabled={isReadOnly} /> diff --git a/packages/fields/src/types/Uuid/views/Field.js b/packages/fields/src/types/Uuid/views/Field.js index df5ee28c3f9..36bd7a4d789 100644 --- a/packages/fields/src/types/Uuid/views/Field.js +++ b/packages/fields/src/types/Uuid/views/Field.js @@ -5,7 +5,7 @@ import { jsx } from '@emotion/core'; import { FieldContainer, FieldLabel, FieldDescription, FieldInput } from '@arch-ui/fields'; import { Input } from '@arch-ui/input'; -const UuidField = ({ onChange, autoFocus, field, errors, value: serverValue }) => { +const UuidField = ({ onChange, autoFocus, field, errors, value: serverValue, isReadOnly }) => { const handleChange = event => { onChange(event.target.value); }; @@ -30,6 +30,7 @@ const UuidField = ({ onChange, autoFocus, field, errors, value: serverValue }) = placeholder={canRead ? undefined : error.message} onChange={handleChange} id={htmlID} + disabled={isReadOnly} /> diff --git a/test-projects/access-control/cypress/integration/field/admin-ui.js b/test-projects/access-control/cypress/integration/field/admin-ui.js index d67b8ae58d1..af6e93bb93e 100644 --- a/test-projects/access-control/cypress/integration/field/admin-ui.js +++ b/test-projects/access-control/cypress/integration/field/admin-ui.js @@ -203,14 +203,14 @@ describe('Access Control Fields > Admin UI', () => { }); }); - it('does not show non-updatable inputs', () => { + it('shows non-updatable inputs as disabled', () => { fieldAccessVariations .filter(({ update, read }) => !update && read) .forEach(access => { const field = getFieldName(access); cy.get(`label[for="ks-input-${field}"]`) - .should('not.exist') - .then(() => cy.get(`#ks-input-${field}`).should('not.exist')); + .should('exist') + .then(() => cy.get(`#ks-input-${field}`).should('be.disabled')); }); }); @@ -241,14 +241,14 @@ describe('Access Control Fields > Admin UI', () => { }); }); - it.skip('does not show non-updatable inputs', () => { + it.skip('shows non-updatable inputs as disabled', () => { fieldAccessVariations .filter(({ update, read }) => !update && read) .forEach(access => { const field = getFieldName(access); cy.get(`label[for="ks-input-${field}"]`) - .should('not.exist') - .then(() => cy.get(`#ks-input-${field}`).should('not.exist')); + .should('exist') + .then(() => cy.get(`#ks-input-${field}`).should('be.disabled')); }); }); diff --git a/test-projects/basic/cypress/integration/readOnly_spec.js b/test-projects/basic/cypress/integration/readOnly_spec.js new file mode 100644 index 00000000000..6e91f1e2401 --- /dev/null +++ b/test-projects/basic/cypress/integration/readOnly_spec.js @@ -0,0 +1,29 @@ +const path = '/admin/users?fields=_label_%2Cdob%2ClastOnline'; + +const getCellFromSecondRow = index => + `#ks-list-table tbody > tr:nth-child(2) > td:nth-child(${index})`; + +describe('ReadOnly Fields', () => { + it('Ensure readonly fields are rendered as disabled', () => { + cy.visit('/admin/read-only-lists'); + + cy.get('a[href^="/admin/read-only-lists/"]:first').click({ force: true }); + + ['slug', 'status', 'author', 'views', 'price', 'currency', 'hero'].forEach(field => { + cy.get(`label[for="ks-input-${field}"]`) + .should('exist') + .then(() => cy.get(`#ks-input-${field}`).should('be.disabled')); + }); + + // markdown field rendering + cy.get(`label[for="ks-input-markdownValue"]`) + .should('exist') + .then($label => { + cy.get($label) + .next() + .within(() => { + cy.get('button').should('be.disabled'); + }); + }); + }); +}); diff --git a/test-projects/basic/data.js b/test-projects/basic/data.js index 5ae9f53d9a0..22c3d371174 100644 --- a/test-projects/basic/data.js +++ b/test-projects/basic/data.js @@ -107,5 +107,10 @@ module.exports = { name: 'Number comparison', }, ], + ReadOnlyList: [ + { + name: 'ReadOnly', + }, + ], User: users.map(user => ({ ...user, password: 'password' })), }; diff --git a/test-projects/basic/index.js b/test-projects/basic/index.js index 324d237a5e3..1481621a834 100644 --- a/test-projects/basic/index.js +++ b/test-projects/basic/index.js @@ -14,6 +14,7 @@ const { Url, Decimal, OEmbed, + Slug, Unsplash, Virtual, } = require('@keystonejs/fields'); @@ -205,6 +206,55 @@ keystone.createList('Post', { }, }); +keystone.createList('ReadOnlyList', { + fields: { + name: { type: Text }, + slug: { type: Slug, adminConfig: { isReadOnly: true } }, + status: { + type: Select, + defaultValue: 'draft', + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + adminConfig: { isReadOnly: true }, + }, + author: { + type: Relationship, + ref: 'User', + adminConfig: { isReadOnly: true }, + }, + views: { type: Integer, adminConfig: { isReadOnly: true } }, + price: { type: Decimal, symbol: '$', adminConfig: { isReadOnly: true } }, + currency: { type: Text, adminConfig: { isReadOnly: true } }, + hero: { type: File, adapter: fileAdapter, adminConfig: { isReadOnly: true } }, + markdownValue: { type: Markdown, adminConfig: { isReadOnly: true } }, + value: { + type: Content, + blocks: [ + ...(cloudinaryAdapter + ? [[CloudinaryImage.blocks.image, { adapter: cloudinaryAdapter }]] + : []), + ...(embedAdapter ? [[OEmbed.blocks.oEmbed, { adapter: embedAdapter }]] : []), + ...(unsplash.accessKey + ? [[Unsplash.blocks.unsplashImage, { attribution: 'KeystoneJS', ...unsplash }]] + : []), + Content.blocks.blockquote, + Content.blocks.orderedList, + Content.blocks.unorderedList, + Content.blocks.link, + Content.blocks.heading, + ], + adminConfig: { isReadOnly: true }, + }, + }, + adminConfig: { + defaultPageSize: 20, + defaultColumns: 'name, status', + defaultSort: 'name', + }, +}); + keystone.createList('PostCategory', { fields: { name: { type: Text },