Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dev-test/backends/test/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ collections: # A list of collections the CMS should be able to edit
search_fields: ['title', 'body']
value_field: 'title'
- { label: 'Title', name: 'title', widget: 'string' }
- { label: 'ID', name: 'uuid', widget: 'uuid', read_only: true, prefix: '', use_b32_encoding: false }
- { label: 'Boolean', name: 'boolean', widget: 'boolean', default: true }
- { label: 'Map', name: 'map', widget: 'map' }
- { label: 'Text', name: 'text', widget: 'text', hint: 'Plain text, not markdown' }
Expand Down
2 changes: 1 addition & 1 deletion dev-test/backends/test/index.html

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions dev-test/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ collections: # A list of collections the CMS should be able to edit
search_fields: ['title', 'body']
value_field: 'title'
- { label: 'Title', name: 'title', widget: 'string' }
- { label: 'ID', name: 'uuid', widget: 'uuid', read_only: true, prefix: '', use_b32_encoding: false }
- { label: 'Boolean', name: 'boolean', widget: 'boolean', default: true }
- { label: 'Map', name: 'map', widget: 'map' }
- { label: 'Text', name: 'text', widget: 'text', hint: 'Plain text, not markdown' }
Expand Down
2 changes: 1 addition & 1 deletion dev-test/index.html

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/decap-cms-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"decap-cms-widget-select": "^3.3.0",
"decap-cms-widget-string": "^3.2.0",
"decap-cms-widget-text": "^3.2.0",
"decap-cms-widget-uuid": "^3.0.0",
"immutable": "^3.7.6",
"lodash": "^4.17.11",
"prop-types": "^15.7.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/decap-cms-app/src/extensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import DecapCmsWidgetMap from 'decap-cms-widget-map';
import DecapCmsWidgetDatetime from 'decap-cms-widget-datetime';
import DecapCmsWidgetCode from 'decap-cms-widget-code';
import DecapCmsWidgetColorString from 'decap-cms-widget-colorstring';
import DecapCmsWidgetUuid from 'decap-cms-widget-uuid';
// Editor Components
import image from 'decap-cms-editor-component-image';
// Locales
Expand Down Expand Up @@ -59,6 +60,7 @@ CMS.registerWidget([
DecapCmsWidgetDatetime.Widget(),
DecapCmsWidgetCode.Widget(),
DecapCmsWidgetColorString.Widget(),
DecapCmsWidgetUuid.Widget(),
]);
CMS.registerEditorComponent(image);
CMS.registerEditorComponent({
Expand Down
7 changes: 7 additions & 0 deletions packages/decap-cms-widget-uuid/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.


**Note:** Version bump only for package decap-cms-widget-uuid
9 changes: 9 additions & 0 deletions packages/decap-cms-widget-uuid/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# decap-cms-widget-uuid

UUID widget for Decap CMS.

## Options

- `read_only`: Prevent editing the generated UUID. Defaults to `true`.
- `prefix`: String prepended to the generated UUID. Defaults to an empty string.
- `use_b32_encoding`: Encode the UUID using unpadded, lowercase RFC 4648 Base32. Defaults to `false`.
31 changes: 31 additions & 0 deletions packages/decap-cms-widget-uuid/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "decap-cms-widget-uuid",
"description": "Widget for UUID values in Decap CMS.",
"version": "3.0.0",
"homepage": "https://www.decapcms.org/docs/widgets/#uuid",
"repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-uuid",
"bugs": "https://github.com/decaporg/decap-cms/issues",
"module": "dist/esm/index.js",
"main": "dist/decap-cms-widget-uuid.js",
"license": "MIT",
"keywords": [
"decap-cms",
"widget",
"uuid"
],
"sideEffects": false,
"scripts": {
"develop": "npm run build:esm -- --watch",
"build": "cross-env NODE_ENV=production webpack",
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward"
},
"dependencies": {
"base32-encode": "^2.0.0",
"hex-to-array-buffer": "^2.0.0"
},
"peerDependencies": {
"decap-cms-ui-default": "^3.0.0",
"prop-types": "^15.7.2",
"react": "^19.1.0"
}
}
84 changes: 84 additions & 0 deletions packages/decap-cms-widget-uuid/src/UuidControl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react';
import PropTypes from 'prop-types';
import base32Encode from 'base32-encode';
import hexToArrayBuffer from 'hex-to-array-buffer';

export default class UuidControl extends React.Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
forID: PropTypes.string,
value: PropTypes.node,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
};

// The selection to maintain for the input element
_sel = 0;

// The input element ref
_el = null;

// NOTE: This prevents the cursor from jumping to the end of the text for
// nested inputs. In other words, this is not an issue on top-level text
// fields such as the `title` of a collection post. However, it becomes an
// issue on fields nested within other components, namely widgets nested
// within a `markdown` widget. For example, the alt text on a block image
// within markdown.
// SEE: https://github.com/decaporg/decap-cms/issues/4539
// SEE: https://github.com/decaporg/decap-cms/issues/3578
componentDidUpdate() {
if (this._el && this._el.selectionStart !== this._sel) {
this._el.setSelectionRange(this._sel, this._sel);
}
}

// componentDidMount is used for generate a UUID when the page loads for the first time
componentDidMount() {
const { value, field, onChange, collection, locale } = this.props;
const defaultLocale = collection?.getIn(['i18n', 'default_locale']);
const isTranslatable = field.get('i18n') === 'translate';
const shouldGenerate = !locale || !defaultLocale || locale === defaultLocale || isTranslatable;

if (!value && shouldGenerate) {
const prefix = field.get('prefix', '');
const useB32Encoding = field.get('use_b32_encoding', false);
const uuid = crypto.randomUUID();
const uuidFormatted = useB32Encoding ? this.uuidToB32(uuid) : uuid;
onChange(prefix + uuidFormatted);
}
}

uuidToB32 = uuid => {
const bytes = hexToArrayBuffer(uuid.replace(/-/g, '') || '');
const encodedUUID = base32Encode(bytes, 'RFC4648', { padding: false });
return encodedUUID.toLowerCase();
};

handleChange = e => {
this._sel = e.target.selectionStart;
this.props.onChange(e.target.value);
};

render() {
const { field, forID, value, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props;
const readOnly = field.get('read_only', true);

return (
<input
ref={el => {
this._el = el;
}}
type="text"
id={forID}
readOnly={readOnly}
style={{ fontFamily: 'monospace', opacity: readOnly ? '0.5' : '1.0' }}
className={classNameWrapper}
value={value || ''}
onChange={this.handleChange}
onFocus={setActiveStyle}
onBlur={setInactiveStyle}
/>
);
}
}
13 changes: 13 additions & 0 deletions packages/decap-cms-widget-uuid/src/UuidPreview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { WidgetPreviewContainer } from 'decap-cms-ui-default';

function UuidPreview({ value }) {
return <WidgetPreviewContainer>{value}</WidgetPreviewContainer>;
}

UuidPreview.propTypes = {
value: PropTypes.node,
};

export default UuidPreview;
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react';
import { fromJS } from 'immutable';
import { render } from '@testing-library/react';

import UuidControl from '../UuidControl';

jest.mock('base32-encode', () => jest.fn(() => 'AAAAAAAAABAABAAAAAAAAAAAAA'));
jest.mock('hex-to-array-buffer', () => jest.fn(value => value));

const UUID = '00000000-0000-4000-8000-000000000000';

function setup(overrides = {}) {
const props = {
collection: fromJS({}),
field: fromJS({}),
onChange: jest.fn(),
forID: 'test-uuid',
classNameWrapper: '',
setActiveStyle: jest.fn(),
setInactiveStyle: jest.fn(),
...overrides,
};

return { ...render(<UuidControl {...props} />), props };
}

describe('UuidControl', () => {
beforeEach(() => {
jest.spyOn(global.crypto, 'randomUUID').mockReturnValue(UUID);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('generates a UUID with a prefix when no value exists', () => {
const { props } = setup({ field: fromJS({ prefix: 'post/' }) });

expect(props.onChange).toHaveBeenCalledWith(`post/${UUID}`);
});

it('generates a lowercase Base32 UUID when configured', () => {
const { props } = setup({ field: fromJS({ use_b32_encoding: true }) });

expect(props.onChange).toHaveBeenCalledWith('aaaaaaaaabaabaaaaaaaaaaaaa');
});

it('preserves an existing value', () => {
const { props } = setup({ value: 'existing-id' });

expect(props.onChange).not.toHaveBeenCalled();
});

it.each(['none', 'duplicate'])(
'does not generate in a non-default locale for an i18n: %s field',
fieldI18n => {
const { props } = setup({
collection: fromJS({ i18n: { default_locale: 'en' } }),
field: fromJS({ i18n: fieldI18n }),
locale: 'fr',
});

expect(props.onChange).not.toHaveBeenCalled();
},
);

it('generates in a non-default locale for a translatable field', () => {
const { props } = setup({
collection: fromJS({ i18n: { default_locale: 'en' } }),
field: fromJS({ i18n: 'translate' }),
locale: 'fr',
});

expect(props.onChange).toHaveBeenCalledWith(UUID);
});
});
14 changes: 14 additions & 0 deletions packages/decap-cms-widget-uuid/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import controlComponent from './UuidControl';
import previewComponent from './UuidPreview';

function Widget(opts = {}) {
return {
name: 'uuid',
controlComponent,
previewComponent,
...opts,
};
}

export const DecapCmsWidgetUuid = { Widget, controlComponent, previewComponent };
export default DecapCmsWidgetUuid;
3 changes: 3 additions & 0 deletions packages/decap-cms-widget-uuid/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { getConfig } = require('../../scripts/webpack.js');

module.exports = getConfig();
Loading