diff --git a/ui/app/adapters/transform.js b/ui/app/adapters/transform.js new file mode 100644 index 000000000..1f7627ba1 --- /dev/null +++ b/ui/app/adapters/transform.js @@ -0,0 +1,98 @@ +import { assign } from '@ember/polyfills'; +import { allSettled } from 'rsvp'; +import ApplicationAdapter from './application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + createOrUpdate(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const { id } = snapshot; + let url = this.urlForTransformations(snapshot.record.get('backend'), id); + + return this.ajax(url, 'POST', { data }); + }, + + createRecord() { + return this.createOrUpdate(...arguments); + }, + + updateRecord() { + return this.createOrUpdate(...arguments, 'update'); + }, + + deleteRecord(store, type, snapshot) { + const { id } = snapshot; + return this.ajax(this.urlForTransformations(snapshot.record.get('backend'), id), 'DELETE'); + }, + + pathForType() { + return 'transform'; + }, + + urlForTransformations(backend, id) { + let url = `${this.buildURL()}/${encodePath(backend)}/transformation`; + if (id) { + url = url + '/' + encodePath(id); + } + return url; + }, + + optionsForQuery(id) { + let data = {}; + if (!id) { + data['list'] = true; + } + return { data }; + }, + + fetchByQuery(store, query) { + const { id, backend } = query; + const queryAjax = this.ajax(this.urlForTransformations(backend, id), 'GET', this.optionsForQuery(id)); + + return allSettled([queryAjax]).then(results => { + // query result 404d, so throw the adapterError + if (!results[0].value) { + throw results[0].reason; + } + let resp = { + id, + name: id, + backend, + data: {}, + }; + + results.forEach(result => { + if (result.value) { + if (result.value.data.roles) { + // TODO: Check if this is needed and remove if not + resp.data = assign({}, resp.data, { zero_address_roles: result.value.data.roles }); + } else { + let d = result.value.data; + if (d.templates) { + // In Transformations data goes up as "template", but comes down as "templates" + // To keep the keys consistent we're translating here + d = { + ...d, + template: [d.templates], + }; + delete d.templates; + } + resp.data = assign({}, resp.data, d); + } + } + }); + return resp; + }); + }, + + query(store, type, query) { + return this.fetchByQuery(store, query); + }, + + queryRecord(store, type, query) { + return this.fetchByQuery(store, query); + }, +}); diff --git a/ui/app/adapters/transform/role.js b/ui/app/adapters/transform/role.js new file mode 100644 index 000000000..aef79adf7 --- /dev/null +++ b/ui/app/adapters/transform/role.js @@ -0,0 +1,25 @@ +import ApplicationAdapater from '../application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; + +export default ApplicationAdapater.extend({ + namespace: 'v1', + + pathForType() { + return 'role'; + }, + + _url(backend, id) { + let type = this.pathForType(); + let base = `/v1/${encodePath(backend)}/${type}`; + if (id) { + return `${base}/${encodePath(id)}`; + } + return base + '?list=true'; + }, + + query(store, type, query) { + return this.ajax(this._url(query.backend), 'GET').then(result => { + return result; + }); + }, +}); diff --git a/ui/app/adapters/transform/template.js b/ui/app/adapters/transform/template.js new file mode 100644 index 000000000..e88825677 --- /dev/null +++ b/ui/app/adapters/transform/template.js @@ -0,0 +1,25 @@ +import ApplicationAdapater from '../application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; + +export default ApplicationAdapater.extend({ + namespace: 'v1', + + pathForType() { + return 'template'; + }, + + _url(backend, id) { + let type = this.pathForType(); + let base = `${this.buildURL()}/${encodePath(backend)}/${type}`; + if (id) { + return `${base}/${encodePath(id)}`; + } + return base + '?list=true'; + }, + + query(store, type, query) { + return this.ajax(this._url(query.backend), 'GET').then(result => { + return result; + }); + }, +}); diff --git a/ui/app/components/mount-backend-form.js b/ui/app/components/mount-backend-form.js index 683e5f29d..6328e38ed 100644 --- a/ui/app/components/mount-backend-form.js +++ b/ui/app/components/mount-backend-form.js @@ -3,7 +3,7 @@ import { computed } from '@ember/object'; import Component from '@ember/component'; import { task } from 'ember-concurrency'; import { methods } from 'vault/helpers/mountable-auth-methods'; -import { engines, KMIP } from 'vault/helpers/mountable-secret-engines'; +import { engines, KMIP, TRANSFORM } from 'vault/helpers/mountable-secret-engines'; const METHODS = methods(); const ENGINES = engines(); @@ -56,11 +56,10 @@ export default Component.extend({ }), engines: computed('version.features[]', function() { - if (this.version.hasFeature('KMIP')) { - return ENGINES.concat([KMIP]); - } else { - return ENGINES; + if (this.get('version.isEnterprise')) { + return ENGINES.concat([KMIP, TRANSFORM]); } + return ENGINES; }), willDestroy() { diff --git a/ui/app/components/transform-create-form.js b/ui/app/components/transform-create-form.js new file mode 100644 index 000000000..548e2dd85 --- /dev/null +++ b/ui/app/components/transform-create-form.js @@ -0,0 +1,3 @@ +import TransformBase from './transform-edit-base'; + +export default TransformBase.extend({}); diff --git a/ui/app/components/transform-edit-base.js b/ui/app/components/transform-edit-base.js new file mode 100644 index 000000000..3673a4582 --- /dev/null +++ b/ui/app/components/transform-edit-base.js @@ -0,0 +1,104 @@ +import { inject as service } from '@ember/service'; +import { or } from '@ember/object/computed'; +import { isBlank } from '@ember/utils'; +import { task, waitForEvent } from 'ember-concurrency'; +import Component from '@ember/component'; +import { set, get } from '@ember/object'; +import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; +import keys from 'vault/lib/keycodes'; + +const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; + +export default Component.extend(FocusOnInsertMixin, { + router: service(), + wizard: service(), + + mode: null, + // TODO: Investigate if we need all of these + emptyData: '{\n}', + onDataChange() {}, + onRefresh() {}, + model: null, + requestInFlight: or('model.isLoading', 'model.isReloading', 'model.isSaving'), + + init() { + this._super(...arguments); + this.set('backendType', 'transform'); + }, + + willDestroyElement() { + this._super(...arguments); + if (this.model && this.model.isError) { + this.model.rollbackAttributes(); + } + }, + + waitForKeyUp: task(function*() { + while (true) { + let event = yield waitForEvent(document.body, 'keyup'); + this.onEscape(event); + } + }) + .on('didInsertElement') + .cancelOn('willDestroyElement'), + + transitionToRoute() { + this.get('router').transitionTo(...arguments); + }, + + onEscape(e) { + if (e.keyCode !== keys.ESC || this.get('mode') !== 'show') { + return; + } + this.transitionToRoute(LIST_ROOT_ROUTE); + }, + + hasDataChanges() { + get(this, 'onDataChange')(get(this, 'model.hasDirtyAttributes')); + }, + + persist(method, successCallback) { + const model = get(this, 'model'); + return model[method]().then(() => { + if (!get(model, 'isError')) { + if (this.get('wizard.featureState') === 'role') { + this.get('wizard').transitionFeatureMachine('role', 'CONTINUE', this.get('backendType')); + } + successCallback(model); + } + }); + }, + + actions: { + createOrUpdate(type, event) { + event.preventDefault(); + const modelId = this.get('model.id') || this.get('model.name'); // transform comes in as model.name + // prevent from submitting if there's no key + // maybe do something fancier later + if (type === 'create' && isBlank(modelId)) { + return; + } + + this.persist('save', () => { + this.hasDataChanges(); + this.transitionToRoute(SHOW_ROUTE, modelId); + }); + }, + + setValue(key, event) { + set(get(this, 'model'), key, event.target.checked); + }, + + refresh() { + this.get('onRefresh')(); + }, + + delete() { + this.persist('destroyRecord', () => { + this.hasDataChanges(); + this.transitionToRoute(LIST_ROOT_ROUTE); + }); + }, + }, +}); diff --git a/ui/app/components/transform-edit-form.js b/ui/app/components/transform-edit-form.js new file mode 100644 index 000000000..548e2dd85 --- /dev/null +++ b/ui/app/components/transform-edit-form.js @@ -0,0 +1,3 @@ +import TransformBase from './transform-edit-base'; + +export default TransformBase.extend({}); diff --git a/ui/app/components/transform-show-transformation.js b/ui/app/components/transform-show-transformation.js new file mode 100644 index 000000000..548e2dd85 --- /dev/null +++ b/ui/app/components/transform-show-transformation.js @@ -0,0 +1,3 @@ +import TransformBase from './transform-edit-base'; + +export default TransformBase.extend({}); diff --git a/ui/app/components/transformation-edit.js b/ui/app/components/transformation-edit.js new file mode 100644 index 000000000..548e2dd85 --- /dev/null +++ b/ui/app/components/transformation-edit.js @@ -0,0 +1,3 @@ +import TransformBase from './transform-edit-base'; + +export default TransformBase.extend({}); diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js index 19af064a5..7ec283fc4 100644 --- a/ui/app/helpers/mountable-secret-engines.js +++ b/ui/app/helpers/mountable-secret-engines.js @@ -5,6 +5,15 @@ export const KMIP = { value: 'kmip', type: 'kmip', category: 'generic', + requiredFeature: 'KMIP', +}; + +export const TRANSFORM = { + displayName: 'Transform', + value: 'transform', + type: 'transform', + category: 'generic', + requiredFeature: 'Transform Secrets Engine', }; const MOUNTABLE_SECRET_ENGINES = [ diff --git a/ui/app/helpers/options-for-backend.js b/ui/app/helpers/options-for-backend.js index 1a2111e1c..667d55ce5 100644 --- a/ui/app/helpers/options-for-backend.js +++ b/ui/app/helpers/options-for-backend.js @@ -55,6 +55,55 @@ const SECRET_BACKENDS = { editComponent: 'role-ssh-edit', listItemPartial: 'partials/secret-list/ssh-role-item', }, + transform: { + displayName: 'Transformation', + navigateTree: false, + listItemPartial: 'partials/secret-list/transform-transformation-item', + tabs: [ + { + name: 'transformations', + label: 'Transformations', + searchPlaceholder: 'Filter transformations', + item: 'transformation', + create: 'Create transformation', + editComponent: 'transformation-edit', + }, + // TODO: Add tabs as needed + // { + // name: 'roles', + // modelPrefix: 'role/', + // label: 'Roles', + // searchPlaceholder: 'Filter roles', + // item: 'roles', + // create: 'Create role', + // tab: 'role', + // listItemPartial: 'partials/secret-list/item', + // editComponent: 'transform-role-edit', + // }, + // { + // name: 'templates', + // modelPrefix: 'template/', + // label: 'Templates', + // searchPlaceholder: 'Filter templates', + // item: 'templates', + // create: 'Create template', + // tab: 'template', + // listItemPartial: 'partials/secret-list/item', + // editComponent: 'transform-template-edit', + // }, + // { + // name: 'alphabets', + // modelPrefix: 'alphabet/', + // label: 'Alphabets', + // searchPlaceholder: 'Filter alphabets', + // item: 'alphabets', + // create: 'Create alphabet', + // tab: 'alphabet', + // listItemPartial: 'partials/secret-list/item', + // editComponent: 'alphabet-edit', + // }, + ], + }, transit: { searchPlaceholder: 'Filter keys', item: 'key', diff --git a/ui/app/helpers/supported-secret-backends.js b/ui/app/helpers/supported-secret-backends.js index 9c86859b7..1cc6274e4 100644 --- a/ui/app/helpers/supported-secret-backends.js +++ b/ui/app/helpers/supported-secret-backends.js @@ -1,6 +1,16 @@ import { helper as buildHelper } from '@ember/component/helper'; -const SUPPORTED_SECRET_BACKENDS = ['aws', 'cubbyhole', 'generic', 'kv', 'pki', 'ssh', 'transit', 'kmip']; +const SUPPORTED_SECRET_BACKENDS = [ + 'aws', + 'cubbyhole', + 'generic', + 'kv', + 'pki', + 'ssh', + 'transit', + 'kmip', + 'transform', +]; export function supportedSecretBackends() { return SUPPORTED_SECRET_BACKENDS; diff --git a/ui/app/models/transform.js b/ui/app/models/transform.js new file mode 100644 index 000000000..ccafb7f28 --- /dev/null +++ b/ui/app/models/transform.js @@ -0,0 +1,96 @@ +import { computed } from '@ember/object'; +import DS from 'ember-data'; +import { apiPath } from 'vault/macros/lazy-capabilities'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import attachCapabilities from 'vault/lib/attach-capabilities'; + +const { attr } = DS; + +// these arrays define the order in which the fields will be displayed +// see +//https://www.vaultproject.io/api-docs/secret/transform#create-update-transformation +const TYPES = [ + { + value: 'fpe', + displayName: 'Format Preserving Encryption (FPE)', + }, + { + value: 'masking', + displayName: 'Masking', + }, +]; + +const TWEAK_SOURCE = [ + { + value: 'supplied', + displayName: 'supplied', + }, + { + value: 'generated', + displayName: 'generated', + }, + { + value: 'internal', + displayName: 'internal', + }, +]; + +const Model = DS.Model.extend({ + useOpenAPI: false, + name: attr('string', { + // TODO: make this required for making a transformation + label: 'Name', + fieldValue: 'id', + readOnly: true, + subText: 'The name for your transformation. This cannot be edited later.', + }), + type: attr('string', { + defaultValue: 'fpe', + label: 'Type', + possibleValues: TYPES, + subText: + 'Vault provides two types of transformations: Format Preserving Encryption (FPE) is reversible, while Masking is not. This cannot be edited later.', + }), + tweak_source: attr('string', { + defaultValue: 'supplied', + label: 'Tweak source', + possibleValues: TWEAK_SOURCE, + subText: `A tweak value is used when performing FPE transformations. This can be supplied, generated, or internal.`, // TODO: I do not include the link here. Need to figure out the best way to approach this. + }), + masking_character: attr('string', { + characterLimit: 1, + defaultValue: '*', + label: 'Masking character', + subText: 'Specify which character you’d like to mask your data.', + }), + template: attr('array', { + editType: 'searchSelect', + fallbackComponent: 'string-list', + label: 'Template', // TODO: make this required for making a transformation + models: ['transform/template'], + selectLimit: 1, + subLabel: 'Template Name', + subText: + 'Templates allow Vault to determine what and how to capture the value to be transformed. Type to use an existing template or create a new one.', + }), + allowed_roles: attr('array', { + editType: 'searchSelect', + label: 'Allowed roles', + fallbackComponent: 'string-list', + models: ['transform/role'], + subText: 'Search for an existing role, type a new role to create it, or use a wildcard (*).', + }), + transformAttrs: computed('type', function() { + if (this.type === 'masking') { + return ['name', 'type', 'masking_character', 'template', 'allowed_roles']; + } + return ['name', 'type', 'tweak_source', 'template', 'allowed_roles']; + }), + transformFieldAttrs: computed('transformAttrs', function() { + return expandAttributeMeta(this, this.get('transformAttrs')); + }), +}); + +export default attachCapabilities(Model, { + updatePath: apiPath`transform/transformation/${'id'}`, +}); diff --git a/ui/app/models/transform/role.js b/ui/app/models/transform/role.js new file mode 100644 index 000000000..40c6bd6be --- /dev/null +++ b/ui/app/models/transform/role.js @@ -0,0 +1,3 @@ +import DS from 'ember-data'; + +export default DS.Model.extend({}); diff --git a/ui/app/models/transform/template.js b/ui/app/models/transform/template.js new file mode 100644 index 000000000..40c6bd6be --- /dev/null +++ b/ui/app/models/transform/template.js @@ -0,0 +1,3 @@ +import DS from 'ember-data'; + +export default DS.Model.extend({}); diff --git a/ui/app/router.js b/ui/app/router.js index 916dacbb8..b50720287 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -117,6 +117,8 @@ Router.map(function() { // transit-specific routes this.route('actions-root', { path: '/actions/' }); this.route('actions', { path: '/actions/*secret' }); + // transform-specific routes + // TODO: add these }); }); this.route('policies', { path: '/policies/:type' }, function() { diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index 7a671ff5a..eff65f4b6 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -56,6 +56,7 @@ export default Route.extend({ let types = { transit: 'transit-key', ssh: 'role-ssh', + transform: 'transform', aws: 'role-aws', pki: tab === 'certs' ? 'pki-certificate' : 'role-pki', // secret or secret-v2 @@ -70,6 +71,7 @@ export default Route.extend({ const secret = this.secretParam() || ''; const backend = this.enginePathParam(); const backendModel = this.modelFor('vault.cluster.secrets.backend'); + return hash({ secret, secrets: this.store diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index c79e90972..6fe3edd95 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -71,6 +71,7 @@ export default Route.extend(UnloadModelRoute, { let types = { transit: 'transit-key', ssh: 'role-ssh', + transform: 'transform', aws: 'role-aws', pki: secret && secret.startsWith('cert/') ? 'pki-certificate' : 'role-pki', cubbyhole: 'secret', diff --git a/ui/app/serializers/transform.js b/ui/app/serializers/transform.js new file mode 100644 index 000000000..ab30958d0 --- /dev/null +++ b/ui/app/serializers/transform.js @@ -0,0 +1,19 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + if (payload.data.masking_character) { + payload.data.masking_character = String.fromCharCode(payload.data.masking_character); + } + return this._super(store, primaryModelClass, payload, id, requestType); + }, + + serialize() { + let json = this._super(...arguments); + if (json.template && Array.isArray(json.template)) { + // Transformations should only ever have one template + json.template = json.template[0]; + } + return json; + }, +}); diff --git a/ui/app/services/path-help.js b/ui/app/services/path-help.js index 9a0aa5527..9cd6a57e7 100644 --- a/ui/app/services/path-help.js +++ b/ui/app/services/path-help.js @@ -175,12 +175,13 @@ export default Service.extend({ // Returns relevant information from OpenAPI // as determined by the expandOpenApiProps util getProps(helpUrl, backend) { + // add name of thing you want debug(`Fetching schema properties for ${backend} from ${helpUrl}`); return this.ajax(helpUrl, backend).then(help => { // paths is an array but it will have a single entry // for the scope we're in - const path = Object.keys(help.openapi.paths)[0]; + const path = Object.keys(help.openapi.paths)[0]; // do this or look at name const pathInfo = help.openapi.paths[path]; const params = pathInfo.parameters; let paramProp = {}; @@ -202,7 +203,9 @@ export default Service.extend({ } // TODO: handle post endpoints without requestBody - const props = pathInfo.post.requestBody.content['application/json'].schema.properties; + const props = pathInfo.post + ? pathInfo.post.requestBody.content['application/json'].schema.properties + : {}; // put url params (e.g. {name}, {role}) // at the front of the props list const newProps = assign({}, paramProp, props); diff --git a/ui/app/styles/components/box-radio.scss b/ui/app/styles/components/box-radio.scss index dbc9d2055..1828e1e86 100644 --- a/ui/app/styles/components/box-radio.scss +++ b/ui/app/styles/components/box-radio.scss @@ -7,6 +7,9 @@ color: $grey; margin: $size-7 0 0 0; } +.box-radio-spacing { + margin: $size-6 $size-3 $size-6 0; +} .box-radio { box-sizing: border-box; flex-basis: 7rem; @@ -32,6 +35,10 @@ box-shadow: 0 0 0 1px $grey-light, $box-shadow-middle; } + &.is-disabled { + opacity: 0.5; + } + input[type='radio'].radio { position: absolute; z-index: 1; diff --git a/ui/app/styles/components/tool-tip.scss b/ui/app/styles/components/tool-tip.scss index 36ff39918..c36584c87 100644 --- a/ui/app/styles/components/tool-tip.scss +++ b/ui/app/styles/components/tool-tip.scss @@ -44,12 +44,13 @@ .ember-basic-dropdown-content--below.ember-basic-dropdown-content--right.tool-tip { @include css-top-arrow(8px, $grey, 1px, $grey-dark, calc(100% - 20px)); } +.ember-basic-dropdown-content--above.tool-tip { + @include css-bottom-arrow(8px, $grey, 1px, $grey-dark); + margin-top: -8px; +} .ember-basic-dropdown-content--above.ember-basic-dropdown-content--right.tool-tip { @include css-bottom-arrow(8px, $grey, 1px, $grey-dark, calc(100% - 20px)); } -.ember-basic-dropdown-content--above.tool-tip { - margin-top: -2px; -} .b-checkbox .tool-tip-trigger { position: relative; diff --git a/ui/app/styles/components/transform-edit.scss b/ui/app/styles/components/transform-edit.scss new file mode 100644 index 000000000..574001004 --- /dev/null +++ b/ui/app/styles/components/transform-edit.scss @@ -0,0 +1,8 @@ +.copy-text { + background: $ui-gray-010; + + & > code { + color: $ui-gray-800; + padding: 14px; + } +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 62d76be84..2e684cfd7 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -103,6 +103,7 @@ @import './components/token-expire-warning'; @import './components/toolbar'; @import './components/tool-tip'; +@import './components/transform-edit.scss'; @import './components/transit-card'; @import './components/ttl-picker2'; @import './components/unseal-warning'; diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index deb16d9e5..106353c71 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -21,7 +21,7 @@ label { .b-checkbox .is-label { color: $grey-darker; display: inline-block; - font-size: $size-small; + font-size: $body-size; font-weight: $font-weight-bold; &:not(:last-child) { @@ -70,6 +70,11 @@ label { } } +.sub-text { + color: $grey; + margin-bottom: 0.25rem; + font-size: $size-8; +} .input, .textarea, .select select { @@ -209,6 +214,12 @@ label { .field-body .field { margin-bottom: 0; } +.field { + // cannot use :read-only selector because tag used for other purposes + &.is-readOnly { + background-color: $ui-gray-100; + } +} .field.has-addons { flex-wrap: wrap; .control { diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index ecc7bd6ad..a9c76e02f 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -168,6 +168,10 @@ .has-top-margin-xl { margin-top: $spacing-xl; } +.has-border-bottom-light { + border-radius: 0; + border-bottom: 1px solid $grey-light; +} .has-border-danger { border: 1px solid $danger; } diff --git a/ui/app/templates/components/mount-backend-form.hbs b/ui/app/templates/components/mount-backend-form.hbs index 63cd367e5..0fd3bf78a 100644 --- a/ui/app/templates/components/mount-backend-form.hbs +++ b/ui/app/templates/components/mount-backend-form.hbs @@ -49,34 +49,22 @@
vault write <backend>/encode/<your role name> value=<enter your value here> tweak=<base-64 string>
+ vault write <backend>/decode/<your role name> value=<enter your value here> tweak=<base-64 string>
+ {{model.id}}
+ {{/if}}
+ {{attr.options.subText}}
+ {{/if}} {{/unless}} {{#if attr.options.possibleValues}} {{else if (eq attr.options.editType "searchSelect")}}{{subLabel}}
+ {{/if}} + {{#if subText}} +{{subText}}
+ {{/if}} + {{#unless (gte selectedOptions.length selectLimit)}} + {{#power-select-with-create + options=options + search=search + onchange=(action "selectOption") + oncreate=(action "createOption") + placeholderComponent=(component "search-select-placeholder") + renderInPlace=true + searchField="searchText" + verticalPosition="below" + showCreateWhen=(action "hideCreateOptionOnSameID") + buildSuggestion=(action "constructSuggestion") as |option| + }} + {{#if shouldRenderName}} + {{option.name}} + + {{option.id}} + + {{else}} {{option.id}} - - {{else}} - {{option.id}} - {{/if}} - {{/power-select-with-create}} + {{/if}} + {{/power-select-with-create}} + {{/unless}}string
| | This is the string that will show on the box radio option. |
+| type | string
| | type is the key that the radio input will be identified by. Please use a value without spaces. |
+| glyph | string
| | glyph is the name of the icon that will be used in the box |
+| groupValue | string
| | The key of the radio option that is currently selected for this radio group |
+| groupName | string
| | The name (key) of the group that this radio option belongs to |
+| onRadioChange | function
| | This callback will trigger when the radio option is selected (if enabled) |
+| [disabled] | boolean
| false
| This parameter controls whether the radio option is selectable. If not, it will be grayed out and show a tooltip. |
+| [tooltipMessage] | string
| "default"
| The message that shows in the tooltip if the radio option is disabled |
+
+**Example**
+
+```js
+