diff --git a/.changelog/11188.txt b/.changelog/11188.txt
new file mode 100644
index 0000000000..d8d62daa02
--- /dev/null
+++ b/.changelog/11188.txt
@@ -0,0 +1,3 @@
+```release-note:feature
+ui: Added initial support for admin partition CRUD
+```
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 91e04262cb..b62764a253 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -22,7 +22,7 @@ references:
test-results: &TEST_RESULTS_DIR /tmp/test-results
cache:
- yarn: &YARN_CACHE_KEY consul-ui-v4-{{ checksum "ui/yarn.lock" }}
+ yarn: &YARN_CACHE_KEY consul-ui-v5-{{ checksum "ui/yarn.lock" }}
rubygem: &RUBYGEM_CACHE_KEY static-site-gems-v1-{{ checksum "Gemfile.lock" }}
environment: &ENVIRONMENT
@@ -602,7 +602,7 @@ jobs:
- run:
name: install yarn packages
- command: cd ui && yarn install
+ command: cd ui && yarn install && cd packages/consul-ui && yarn install
- save_cache:
key: *YARN_CACHE_KEY
diff --git a/ui/package.json b/ui/package.json
index 4c22878ed2..06b57506c0 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -11,7 +11,7 @@
"scripts": {
"doc:toc": "doctoc README.md",
"compliance": "npm-run-all compliance:*",
- "compliance:licenses": "license-checker --summary --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL' --excludePackages 'consul-ui@2.2.0;'"
+ "compliance:licenses": "license-checker --summary --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL' --excludePackages 'consul-ui@2.2.0;consul-acls@0.1.0;consul-partitions@0.1.0'"
},
"devDependencies": {
diff --git a/ui/packages/consul-acls/package.json b/ui/packages/consul-acls/package.json
new file mode 100644
index 0000000000..b2a513fb82
--- /dev/null
+++ b/ui/packages/consul-acls/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "consul-acls",
+ "version": "0.1.0",
+ "private": true
+}
diff --git a/ui/packages/consul-acls/vendor/consul-acls/routes.js b/ui/packages/consul-acls/vendor/consul-acls/routes.js
new file mode 100644
index 0000000000..cf4dc72d8b
--- /dev/null
+++ b/ui/packages/consul-acls/vendor/consul-acls/routes.js
@@ -0,0 +1,18 @@
+(routes => routes({
+ dc: {
+ acls: {
+ tokens: {
+ _options: {
+ abilities: ['read tokens'],
+ },
+ },
+ },
+ },
+}))(
+ (json, data = document.currentScript.dataset) => {
+ const appNameJS = data.appName.split('-')
+ .map((item, i) => i ? `${item.substr(0, 1).toUpperCase()}${item.substr(1)}` : item)
+ .join('');
+ data[`${appNameJS}Routes`] = JSON.stringify(json);
+ }
+);
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/form/README.mdx b/ui/packages/consul-partitions/app/components/consul/partition/form/README.mdx
new file mode 100644
index 0000000000..6f72a5cdef
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/form/README.mdx
@@ -0,0 +1,24 @@
+# Consul::Partition::Form
+
+```hbs preview-template
+
+
+
+
+
+```
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/form/index.hbs b/ui/packages/consul-partitions/app/components/consul/partition/form/index.hbs
new file mode 100644
index 0000000000..7795f03c1f
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/form/index.hbs
@@ -0,0 +1,126 @@
+
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/list/README.mdx b/ui/packages/consul-partitions/app/components/consul/partition/list/README.mdx
new file mode 100644
index 0000000000..326465ee90
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/list/README.mdx
@@ -0,0 +1,32 @@
+# Consul::Partition::List
+
+A presentational component for rendering Consul Partitions
+
+Please note:
+
+- For the moment, make sure you have enabled partitions using developer debug
+ cookies.
+
+```hbs preview-template
+
+
+
+```
+
+
+### Arguments
+
+| Argument/Attribute | Type | Default | Description |
+| --- | --- | --- | --- |
+| `items` | `array` | | An array of Partitions |
+| `ondelete` | `function` | | An action to execute when the `Delete` action is clicked |
+
+### See
+
+- [Component Source Code](./index.js)
+- [Template Source Code](./index.hbs)
+
+---
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/list/index.hbs b/ui/packages/consul-partitions/app/components/consul/partition/list/index.hbs
new file mode 100644
index 0000000000..1dcd6023e4
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/list/index.hbs
@@ -0,0 +1,63 @@
+
+
+{{#if item.DeletedAt}}
+
+ Deleting {{item.Name}}...
+
+{{else}}
+ {{item.Name}}
+{{/if}}
+
+
+{{#if item.Description}}
+
+ Description
+
+ {{item.Description}}
+
+
+{{/if}}
+
+
+{{#if (not item.DeletedAt)}}
+
+
+
+{{#if (can "write partition" item=item)}}
+ Edit
+{{else}}
+ View
+{{/if}}
+
+
+ {{#if (can "delete partition" item=item)}}
+
+
+ Delete
+
+
+
+
+ Confirm delete
+
+
+
+ Are you sure you want to delete this partition?
+
+
+
+ Delete
+
+
+
+
+ {{/if}}
+
+{{/if}}
+
+
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/list/test-support.js b/ui/packages/consul-partitions/app/components/consul/partition/list/test-support.js
new file mode 100644
index 0000000000..c6ec1c4521
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/list/test-support.js
@@ -0,0 +1,18 @@
+export const selectors = () => ({
+ ['.consul-partition-list']: {
+ row: {
+ $: '[data-test-list-row]',
+ partition: 'a',
+ name: '[data-test-partition]',
+ description: '[data-test-description]'
+ }
+ }
+});
+export const pageObject = (collection, clickable, attribute, text, actions) => () => {
+ return collection('.consul-partition-list [data-test-list-row]', {
+ partition: clickable('a'),
+ name: attribute('data-test-partition', '[data-test-partition]'),
+ description: text('[data-test-description]'),
+ ...actions(['edit', 'delete']),
+ });
+};
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/notifications/README.mdx b/ui/packages/consul-partitions/app/components/consul/partition/notifications/README.mdx
new file mode 100644
index 0000000000..e84187b9d0
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/notifications/README.mdx
@@ -0,0 +1,46 @@
+# Consul::Partition::Notifications
+
+A Notification component specifically for Partitions (at some point will be replaced with just using `ember-intl`/`t`.
+
+```hbs preview-template
+
+ Provide a widget to change the @type
+
+
+ create
+ update
+ delete
+
+
+
+ Provide a widget to change the @status
+
+
+ success
+ error
+
+
+
+ Show the notification text
+
+
+
+
+
+```
+
+
+
+## See
+
+- [Template Source Code](./index.hbs)
+
+---
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/notifications/index.hbs b/ui/packages/consul-partitions/app/components/consul/partition/notifications/index.hbs
new file mode 100644
index 0000000000..14eb0c41a7
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/notifications/index.hbs
@@ -0,0 +1,24 @@
+{{#if (eq @type 'create')}}
+ {{#if (eq @status 'success') }}
+ Your partition has been added.
+ {{else}}
+ There was an error adding your partition.
+ {{/if}}
+{{else if (eq @type 'update') }}
+ {{#if (eq @status 'success') }}
+ Your partition has been saved.
+ {{else}}
+ There was an error saving your partition.
+ {{/if}}
+{{ else if (eq @type 'delete')}}
+ {{#if (eq @status 'success') }}
+ Your partition has been marked for deletion.
+ {{else}}
+ There was an error deleting your partition.
+ {{/if}}
+{{/if}}
+{{#let @error.errors.firstObject as |error|}}
+ {{#if error.detail }}
+ {{concat '(' (if error.status (concat error.status ': ')) error.detail ')'}}
+ {{/if}}
+{{/let}}
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/search-bar/README.mdx b/ui/packages/consul-partitions/app/components/consul/partition/search-bar/README.mdx
new file mode 100644
index 0000000000..484b1116c8
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/search-bar/README.mdx
@@ -0,0 +1,30 @@
+# Consul::Partition::SearchBar
+
+Searchbar tailored for searching Partitions. Follows our more generic
+'*::SearchBar' component interface.
+
+```hbs preview-template
+
+```
+
+## See
+
+- [Template Source Code](./index.hbs)
+
+---
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/search-bar/index.hbs b/ui/packages/consul-partitions/app/components/consul/partition/search-bar/index.hbs
new file mode 100644
index 0000000000..1d2999c9b4
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/search-bar/index.hbs
@@ -0,0 +1,98 @@
+
+ <:status as |search|>
+
+{{#let
+
+ (t (concat "components.consul.nspace.search-bar." search.status.key)
+ default=(array
+ (concat "common.search." search.status.key)
+ (concat "common.consul." search.status.key)
+ )
+ )
+
+ (t (concat "components.consul.nspace.search-bar." search.status.value)
+ default=(array
+ (concat "common.search." search.status.value)
+ (concat "common.consul." search.status.value)
+ (concat "common.brand." search.status.value)
+ )
+ )
+
+as |key value|}}
+
+
+ {{key}}
+ {{value}}
+
+
+{{/let}}
+
+
+ <:search as |search|>
+
+
+
+
+ {{t "common.search.searchproperty"}}
+
+
+
+ {{#let components.Optgroup components.Option as |Optgroup Option|}}
+ {{#each @filter.searchproperty.default as |prop|}}
+
+ {{t (concat "common.consul." (lowercase prop))}}
+
+ {{/each}}
+ {{/let}}
+
+
+
+
+ <:sort as |search|>
+
+
+
+ {{#let (from-entries (array
+ (array "Name:asc" (t "common.sort.alpha.asc"))
+ (array "Name:desc" (t "common.sort.alpha.desc"))
+ ))
+ as |selectable|
+ }}
+ {{get selectable @sort.value}}
+ {{/let}}
+
+
+
+ {{#let components.Optgroup components.Option as |Optgroup Option|}}
+
+ {{t "common.sort.alpha.asc"}}
+ {{t "common.sort.alpha.desc"}}
+
+ {{/let}}
+
+
+
+
\ No newline at end of file
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/selector/README.mdx b/ui/packages/consul-partitions/app/components/consul/partition/selector/README.mdx
new file mode 100644
index 0000000000..f802edb1d9
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/selector/README.mdx
@@ -0,0 +1,40 @@
+# Consul::Partition::Selector
+
+A conditional, autoloading, menu component specifically for making it easy to select partitions.
+
+Please note:
+
+- Currently at least, you must add this inside of a `` element.
+- For the moment, make sure you have enabled partitions using developer debug
+ cookies.
+
+```hbs preview-template
+
+```
+
+
+## Arguments
+
+| Argument/Attribute | Type | Default | Description |
+| --- | --- | --- | --- |
+| `dc` | `object` | | The current datacenter |
+| `nspace` | `string` | | The name of the current namespace |
+| `partition` | `string` | | The name of the current partition |
+| `partitions` | `array` | | A list of partition models/objects to use for the selector |
+| `onchange` | `function` | | An event handler, for when partitions are loaded. You probably want to update `@partitions` using this. |
+
+## See
+
+- [Template Source Code](./index.hbs)
+
+---
diff --git a/ui/packages/consul-partitions/app/components/consul/partition/selector/index.hbs b/ui/packages/consul-partitions/app/components/consul/partition/selector/index.hbs
new file mode 100644
index 0000000000..078f360359
--- /dev/null
+++ b/ui/packages/consul-partitions/app/components/consul/partition/selector/index.hbs
@@ -0,0 +1,53 @@
+{{#if (can "choose partitions")}}
+
+
+
+ {{@partition}}
+
+
+ {{#let components.MenuItem components.MenuSeparator as |MenuItem MenuSeparator|}}
+
+ {{#each (reject-by 'DeletedAt' @partitions) as |item|}}
+
+
+ {{item.Name}}
+
+
+ {{/each}}
+ {{#if (can 'manage partitions')}}
+
+
+
+ Manage Admin Partitions
+
+
+ {{/if}}
+ {{/let}}
+
+
+
+{{/if}}
+
diff --git a/ui/packages/consul-partitions/app/templates/dc/partitions/edit.hbs b/ui/packages/consul-partitions/app/templates/dc/partitions/edit.hbs
new file mode 100644
index 0000000000..019ca28bfc
--- /dev/null
+++ b/ui/packages/consul-partitions/app/templates/dc/partitions/edit.hbs
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+{{#let
+
+ route.params.dc
+ route.params.partition
+ route.params.nspace
+
+ loader.data
+ loader.data.isNew
+as |dc partition nspace item create|}}
+
+
+
+
+
+
+ All Partitions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{/let}}
+
+
+
diff --git a/ui/packages/consul-partitions/app/templates/dc/partitions/index.hbs b/ui/packages/consul-partitions/app/templates/dc/partitions/index.hbs
new file mode 100644
index 0000000000..cf905bfd5d
--- /dev/null
+++ b/ui/packages/consul-partitions/app/templates/dc/partitions/index.hbs
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+ {{#let
+
+ (hash
+ value=(or sortBy "Name:asc")
+ change=(action (mut sortBy) value="target.selected")
+ )
+
+ (hash
+ searchproperty=(hash
+ value=(if (not-eq searchproperty undefined)
+ (split searchproperty ',')
+ searchProperties
+ )
+ change=(action (mut searchproperty) value="target.selectedItems")
+ default=searchProperties
+ )
+ )
+
+ loader.data
+
+ as |sort filters items|}}
+
+
+
+
+
+
+
+
+
+
+
+ Create
+
+
+ {{#if (gt items.length 0)}}
+
+ {{/if}}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{#if (gt items.length 0)}}
+ No partitions found
+ {{else}}
+ Welcome to Partitions
+ {{/if}}
+
+
+
+
+ {{#if (gt items.length 0)}}
+ No partitions where found matching that search, or you may not have access to view the namespaces you are searching for.
+ {{else}}
+ There don't seem to be any partitions, or you may not have access to view partitions yet.
+ {{/if}}
+
+
+
+
+ Documentation on partitions
+
+
+ Read the guide
+
+
+
+
+
+
+
+
+
+ {{/let}}
+
+
+
diff --git a/ui/packages/consul-partitions/package.json b/ui/packages/consul-partitions/package.json
new file mode 100644
index 0000000000..c10a455e5b
--- /dev/null
+++ b/ui/packages/consul-partitions/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "consul-partitions",
+ "version": "0.1.0",
+ "private": true
+}
diff --git a/ui/packages/consul-partitions/vendor/consul-partitions/routes.js b/ui/packages/consul-partitions/vendor/consul-partitions/routes.js
new file mode 100644
index 0000000000..3ef4d56a3e
--- /dev/null
+++ b/ui/packages/consul-partitions/vendor/consul-partitions/routes.js
@@ -0,0 +1,38 @@
+(routes => routes({
+ dc: {
+ partitions: {
+ _options: {
+ path: '/partitions',
+ queryParams: {
+ sortBy: 'sort',
+ searchproperty: {
+ as: 'searchproperty',
+ empty: [['Name', 'Description']],
+ },
+ search: {
+ as: 'filter',
+ replace: true,
+ },
+ },
+ abilities: ['read partitions'],
+ },
+ edit: {
+ _options: { path: '/:name' },
+ },
+ create: {
+ _options: {
+ template: 'dc/partitions/edit',
+ path: '/create',
+ abilities: ['create partitions'],
+ },
+ },
+ },
+ },
+}))(
+ (json, data = document.currentScript.dataset) => {
+ const appNameJS = data.appName.split('-')
+ .map((item, i) => i ? `${item.substr(0, 1).toUpperCase()}${item.substr(1)}` : item)
+ .join('');
+ data[`${appNameJS}Routes`] = JSON.stringify(json);
+ }
+);
diff --git a/ui/packages/consul-ui/.docfy-config.js b/ui/packages/consul-ui/.docfy-config.js
index cf6d67d012..a3970301ca 100644
--- a/ui/packages/consul-ui/.docfy-config.js
+++ b/ui/packages/consul-ui/.docfy-config.js
@@ -79,6 +79,12 @@ module.exports = {
pattern: '**/README.mdx',
urlSchema: 'auto',
urlPrefix: 'docs/consul',
+ },
+ {
+ root: `${path.dirname(require.resolve('consul-partitions/package.json'))}/app/components`,
+ pattern: '**/README.mdx',
+ urlSchema: 'auto',
+ urlPrefix: 'docs/consul-partitions',
}
].concat(user.sources),
labels: {
diff --git a/ui/packages/consul-ui/app/abilities/base.js b/ui/packages/consul-ui/app/abilities/base.js
index 58254c6f1c..8294273a2d 100644
--- a/ui/packages/consul-ui/app/abilities/base.js
+++ b/ui/packages/consul-ui/app/abilities/base.js
@@ -38,6 +38,19 @@ export default class BaseAbility extends Ability {
this.permissions.generate(this.resource, ACCESS_WRITE, segment),
];
}
+ // characteristics
+ // TODO: Remove once we have managed to do the scroll pane refactor
+ get isLinkable() {
+ return true;
+ }
+ get isNew() {
+ return this.item.isNew;
+ }
+
+ get isPristine() {
+ return this.item.isPristine;
+ }
+ //
get canRead() {
if (typeof this.item !== 'undefined') {
diff --git a/ui/packages/consul-ui/app/abilities/nspace.js b/ui/packages/consul-ui/app/abilities/nspace.js
index 0271a5b3e5..befbefe292 100644
--- a/ui/packages/consul-ui/app/abilities/nspace.js
+++ b/ui/packages/consul-ui/app/abilities/nspace.js
@@ -7,6 +7,10 @@ export default class NspaceAbility extends BaseAbility {
resource = 'operator';
segmented = false;
+ get isLinkable() {
+ return !this.item.DeletedAt;
+ }
+
get canManage() {
return this.canCreate;
}
diff --git a/ui/packages/consul-ui/app/abilities/partition.js b/ui/packages/consul-ui/app/abilities/partition.js
index aea6ccfffc..034907ab3d 100644
--- a/ui/packages/consul-ui/app/abilities/partition.js
+++ b/ui/packages/consul-ui/app/abilities/partition.js
@@ -1,4 +1,4 @@
-import BaseAbility from './base';
+import BaseAbility from 'consul-ui/abilities/base';
import { inject as service } from '@ember/service';
export default class PartitionAbility extends BaseAbility {
@@ -7,6 +7,10 @@ export default class PartitionAbility extends BaseAbility {
resource = 'operator';
segmented = false;
+ get isLinkable() {
+ return !this.item.DeletedAt;
+ }
+
get canManage() {
return this.canCreate;
}
diff --git a/ui/packages/consul-ui/app/abilities/service.js b/ui/packages/consul-ui/app/abilities/service.js
index 4c78ab0b30..5e806bd6f7 100644
--- a/ui/packages/consul-ui/app/abilities/service.js
+++ b/ui/packages/consul-ui/app/abilities/service.js
@@ -2,4 +2,8 @@ import BaseAbility from './base';
export default class ServiceAbility extends BaseAbility {
resource = 'service';
+
+ get isLinkable() {
+ return this.item.InstanceCount > 0;
+ }
}
diff --git a/ui/packages/consul-ui/app/abilities/upstream.js b/ui/packages/consul-ui/app/abilities/upstream.js
new file mode 100644
index 0000000000..da1991c8e1
--- /dev/null
+++ b/ui/packages/consul-ui/app/abilities/upstream.js
@@ -0,0 +1,9 @@
+import BaseAbility from './base';
+
+export default class UpstreamAbility extends BaseAbility {
+ resource = 'upstream';
+
+ get isLinkable() {
+ return this.item.InstanceCount > 0;
+ }
+}
diff --git a/ui/packages/consul-ui/app/adapters/partition.js b/ui/packages/consul-ui/app/adapters/partition.js
index 542001a006..88fc571749 100644
--- a/ui/packages/consul-ui/app/adapters/partition.js
+++ b/ui/packages/consul-ui/app/adapters/partition.js
@@ -1,4 +1,5 @@
import Adapter from './application';
+import { SLUG_KEY } from 'consul-ui/models/partition';
// Blocking query support for partitions is currently disabled
export default class PartitionAdapter extends Adapter {
@@ -24,4 +25,37 @@ export default class PartitionAdapter extends Adapter {
await respond((headers, body) => delete headers['x-consul-index']);
return respond;
}
+
+ async requestForCreateRecord(request, serialized, data) {
+ return request`
+ PUT /v1/partition/${data[SLUG_KEY]}?${{
+ dc: data.Datacenter,
+ }}
+
+ ${{
+ Name: serialized.Name,
+ Description: serialized.Description,
+ }}
+ `;
+ }
+
+ async requestForUpdateRecord(request, serialized, data) {
+ return request`
+ PUT /v1/partition/${data[SLUG_KEY]}?${{
+ dc: data.Datacenter,
+ }}
+
+ ${{
+ Description: serialized.Description,
+ }}
+ `;
+ }
+
+ async requestForDeleteRecord(request, serialized, data) {
+ return request`
+ DELETE /v1/partition/${data[SLUG_KEY]}?${{
+ dc: data.Datacenter,
+ }}
+ `;
+ }
}
diff --git a/ui/packages/consul-ui/app/components/consul/nspace/list/index.hbs b/ui/packages/consul-ui/app/components/consul/nspace/list/index.hbs
index a309d64d9d..8221de2311 100644
--- a/ui/packages/consul-ui/app/components/consul/nspace/list/index.hbs
+++ b/ui/packages/consul-ui/app/components/consul/nspace/list/index.hbs
@@ -2,7 +2,7 @@
class="consul-nspace-list"
...attributes
@items={{@items}}
- @linkable={{action this.isLinkable}}
+ @linkable="linkable nspace"
as |item|>
{{#if item.DeletedAt}}
diff --git a/ui/packages/consul-ui/app/components/consul/nspace/list/index.js b/ui/packages/consul-ui/app/components/consul/nspace/list/index.js
deleted file mode 100644
index e00c5b8a0b..0000000000
--- a/ui/packages/consul-ui/app/components/consul/nspace/list/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Component from '@glimmer/component';
-
-export default class ConsulNspaceList extends Component {
- isLinkable(item) {
- return !item.DeletedAt;
- }
-}
diff --git a/ui/packages/consul-ui/app/components/consul/service/list/index.hbs b/ui/packages/consul-ui/app/components/consul/service/list/index.hbs
index 0cef5caf1a..fd5eaa5688 100644
--- a/ui/packages/consul-ui/app/components/consul/service/list/index.hbs
+++ b/ui/packages/consul-ui/app/components/consul/service/list/index.hbs
@@ -2,7 +2,7 @@
class="consul-service-list"
...attributes
@items={{@items}}
- @linkable={{action this.isLinkable}}
+ @linkable="linkable service"
as |item index|
>
diff --git a/ui/packages/consul-ui/app/components/consul/service/list/index.js b/ui/packages/consul-ui/app/components/consul/service/list/index.js
deleted file mode 100644
index b90fa5ee41..0000000000
--- a/ui/packages/consul-ui/app/components/consul/service/list/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-
-export default class ConsulServiceList extends Component {
- @action
- isLinkable(item) {
- return item.InstanceCount > 0;
- }
-}
diff --git a/ui/packages/consul-ui/app/components/consul/upstream/list/index.hbs b/ui/packages/consul-ui/app/components/consul/upstream/list/index.hbs
index 8ce4b213e5..14ba0027fc 100644
--- a/ui/packages/consul-ui/app/components/consul/upstream/list/index.hbs
+++ b/ui/packages/consul-ui/app/components/consul/upstream/list/index.hbs
@@ -2,7 +2,7 @@
class="consul-upstream-list"
...attributes
@items={{@items}}
- @linkable={{action this.isLinkable}}
+ @linkable="linkable upstream"
as |item index|>
{{#if (gt item.InstanceCount 0)}}
diff --git a/ui/packages/consul-ui/app/components/consul/upstream/list/index.js b/ui/packages/consul-ui/app/components/consul/upstream/list/index.js
deleted file mode 100644
index b90fa5ee41..0000000000
--- a/ui/packages/consul-ui/app/components/consul/upstream/list/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import Component from '@glimmer/component';
-import { action } from '@ember/object';
-
-export default class ConsulServiceList extends Component {
- @action
- isLinkable(item) {
- return item.InstanceCount > 0;
- }
-}
diff --git a/ui/packages/consul-ui/app/components/data-form/index.js b/ui/packages/consul-ui/app/components/data-form/index.js
index b0ce2eced6..b7db7af669 100644
--- a/ui/packages/consul-ui/app/components/data-form/index.js
+++ b/ui/packages/consul-ui/app/components/data-form/index.js
@@ -2,6 +2,7 @@ import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { set, get } from '@ember/object';
import Slotted from 'block-slots';
+import { isChangeset } from 'validated-changeset';
export default Component.extend(Slotted, {
tagName: '',
@@ -41,7 +42,7 @@ export default Component.extend(Slotted, {
setData: function(data) {
let changeset = data;
// convert to a real changeset
- if (typeof this.form !== 'undefined') {
+ if (!isChangeset(data) && typeof this.form !== 'undefined') {
changeset = this.form.setData(data).getData();
}
// mark as creating
diff --git a/ui/packages/consul-ui/app/components/data-writer/index.hbs b/ui/packages/consul-ui/app/components/data-writer/index.hbs
index 2120f5e4a2..61fbb07998 100644
--- a/ui/packages/consul-ui/app/components/data-writer/index.hbs
+++ b/ui/packages/consul-ui/app/components/data-writer/index.hbs
@@ -8,6 +8,7 @@
persist=(action "persist")
delete=(queue (action (mut data)) (action dispatch "REMOVE"))
inflight=(state-matches state (array "persisting" "removing"))
+ disabled=(state-matches state (array "persisting" "removing"))
) as |api|}}
{{yield api}}
diff --git a/ui/packages/consul-ui/app/components/hashicorp-consul/index.hbs b/ui/packages/consul-ui/app/components/hashicorp-consul/index.hbs
index 78efefa5af..da3b637d1f 100644
--- a/ui/packages/consul-ui/app/components/hashicorp-consul/index.hbs
+++ b/ui/packages/consul-ui/app/components/hashicorp-consul/index.hbs
@@ -48,60 +48,13 @@
-
- {{#if (can "choose partitions")}}
-
-
-
- {{@partition}}
-
-
- {{#let components.MenuItem components.MenuSeparator as |MenuItem MenuSeparator|}}
-
- {{#each (reject-by 'DeletedAt' this.partitions) as |item|}}
-
-
- {{item.Name}}
-
-
- {{/each}}
- {{#if (and false (can 'manage partitions'))}}
-
-
-
- Manage Admin Partitions
-
-
- {{/if}}
- {{/let}}
-
-
-
- {{/if}}
-
+
{{#if (can "choose nspaces")}}
{{yield cell.item cell.index}}
@@ -49,10 +46,7 @@
{{yield item index}}
diff --git a/ui/packages/consul-ui/app/components/text-input/README.mdx b/ui/packages/consul-ui/app/components/text-input/README.mdx
new file mode 100644
index 0000000000..fe5266c9e7
--- /dev/null
+++ b/ui/packages/consul-ui/app/components/text-input/README.mdx
@@ -0,0 +1,46 @@
+# TextInput
+
+Form component to be used for entering text values, both short form and long
+form. Currently an inline component but as and when we get chance this will be
+changed to also accept slots for specifying specific parts of the component.
+
+```hbs preview-template
+
+
+
+```
+
+## Arguments
+
+| Argument | Type | Default | Description |
+| --- | --- | --- | --- |
+| `item` | `Object` | | An object whose properties are to be edited |
+| `name` | `String` | '' | An identifier for the property to be edited on the `item` |
+| `label` | `String` | `@name` | A label to use to label the text input element |
+| `placeholder` | `String` | | Equivalent to the HTML `placeholder=""` attribute |
+| `help` | `String` | | Provide some help text for the input (consider using `@validations` instead) |
+| `expanded` | `Boolean` | `false` | Whether to use an expanded textarea or just a normal single line input |
+| `validations` | `Object` | | A `validations` object to be passed to the underlying `validate` modifier |
+| `chart` | `Object` | | A StateChart object (implementing `state` and `dispatch` to be passed to the underlying `validate` modifier |
+
+## See
+
+- [Validate Modifier](../modifiers/validate.mdx)
+- [Template Source Code](./index.hbs)
+
+---
diff --git a/ui/packages/consul-ui/app/components/text-input/index.hbs b/ui/packages/consul-ui/app/components/text-input/index.hbs
new file mode 100644
index 0000000000..b09c9ae7d7
--- /dev/null
+++ b/ui/packages/consul-ui/app/components/text-input/index.hbs
@@ -0,0 +1,48 @@
+
+
+ {{!- add an optional slot here called <:label>-}}
+ {{or @label @name}}
+
+ {{!- add an optional slot here called <:input>?-}}
+ {{#if @expanded}}
+
+ {{else}}
+
+ {{/if}}
+{{#let
+ (or @validations.help @help)
+as |help|}}
+ {{#if help}}
+ {{!- add an optional slot here called <:help>?-}}
+
+ {{help}}
+
+ {{/if}}
+{{/let}}
+
+ {{!- add an optional slot here called <:alert/error/success>?-}}
+ {{get (get @chart.state.context.errors @name) 'message'}}
+
+
diff --git a/ui/packages/consul-ui/app/forms/partition.js b/ui/packages/consul-ui/app/forms/partition.js
new file mode 100644
index 0000000000..eac1aebf49
--- /dev/null
+++ b/ui/packages/consul-ui/app/forms/partition.js
@@ -0,0 +1,7 @@
+import validations from 'consul-ui/validations/nspace';
+import builderFactory from 'consul-ui/utils/form/builder';
+const builder = builderFactory();
+export default function(container, name = '', v = validations, form = builder) {
+ return form(name, {})
+ .setValidators(v);
+}
diff --git a/ui/packages/consul-ui/app/helpers/is.js b/ui/packages/consul-ui/app/helpers/is.js
new file mode 100644
index 0000000000..7618b753b2
--- /dev/null
+++ b/ui/packages/consul-ui/app/helpers/is.js
@@ -0,0 +1,24 @@
+import Helper from 'ember-can/helpers/can';
+import { get } from '@ember/object';
+
+import { camelize } from '@ember/string';
+export const is = (helper, [abilityString, model], properties) => {
+ let { abilityName, propertyName } = helper.can.parse(abilityString);
+ let ability = helper.can.abilityFor(abilityName, model, properties);
+
+ if(typeof ability.getCharacteristicProperty === 'function') {
+ propertyName = ability.getCharacteristicProperty(propertyName);
+ } else {
+ propertyName = camelize(`is-${propertyName}`);
+ }
+
+ helper._removeAbilityObserver();
+ helper._addAbilityObserver(ability, propertyName);
+
+ return get(ability, propertyName);
+}
+export default Helper.extend({
+ compute([abilityString, model], properties) {
+ return is(this, [abilityString, model], properties);
+ },
+});
diff --git a/ui/packages/consul-ui/app/helpers/is.mdx b/ui/packages/consul-ui/app/helpers/is.mdx
new file mode 100644
index 0000000000..5cebfbf395
--- /dev/null
+++ b/ui/packages/consul-ui/app/helpers/is.mdx
@@ -0,0 +1,20 @@
+# is
+
+`{{is "something model" item=item}}` is used to perform a test on based on a
+type of model, almost the same as `ember-can` but reads better to test for a
+characteristic rather than an ability:
+
+```hbs
+
+{{#if (is "crd intention" item=item)}}
+I'm a CRD intention
+{{/if}}
+
+```
+
+Consider using the `test` helper instead.
+
+## See also
+
+- [`test` helper](./test.mdx)
+
diff --git a/ui/packages/consul-ui/app/helpers/state-chart.js b/ui/packages/consul-ui/app/helpers/state-chart.js
new file mode 100644
index 0000000000..f89c01b44d
--- /dev/null
+++ b/ui/packages/consul-ui/app/helpers/state-chart.js
@@ -0,0 +1,10 @@
+import Helper from '@ember/component/helper';
+import { inject as service } from '@ember/service';
+
+export default class StateChartHelper extends Helper {
+ @service('state') state;
+
+ compute([value], hash) {
+ return this.state.stateChart(value);
+ }
+}
diff --git a/ui/packages/consul-ui/app/helpers/test.js b/ui/packages/consul-ui/app/helpers/test.js
new file mode 100644
index 0000000000..66af4cd48d
--- /dev/null
+++ b/ui/packages/consul-ui/app/helpers/test.js
@@ -0,0 +1,14 @@
+import Helper from 'ember-can/helpers/can';
+import { is } from 'consul-ui/helpers/is';
+
+export default Helper.extend({
+ compute([abilityString, model], properties) {
+ switch(true) {
+ case abilityString.startsWith('can '):
+ return super.compute([abilityString.substr(4), model], properties);
+ case abilityString.startsWith('is '):
+ return is(this, [abilityString.substr(3), model], properties);
+ }
+ throw new Error(`${abilityString} is not supported by the 'test' helper.`);
+ },
+});
diff --git a/ui/packages/consul-ui/app/helpers/test.mdx b/ui/packages/consul-ui/app/helpers/test.mdx
new file mode 100644
index 0000000000..21630a488f
--- /dev/null
+++ b/ui/packages/consul-ui/app/helpers/test.mdx
@@ -0,0 +1,16 @@
+# test
+
+`{{and (test "is something model" item=item) (test "can read model")}}` is used
+to perform a test on based on a type of model, almost the same as `ember-can`
+and the `is` helper, but is more generic and extensible whilst reading nicely:
+
+```hbs
+
+{{#if (and (test "is crd intention" item=item) (test "can read intentions"))}}
+I'm a CRD intention
+{{/if}}
+
+```
+
+Consider using this instead of the `can` and `is` helpers, as its more generic.
+
diff --git a/ui/packages/consul-ui/app/initializers/routing.js b/ui/packages/consul-ui/app/initializers/routing.js
deleted file mode 100644
index 266a8fcc33..0000000000
--- a/ui/packages/consul-ui/app/initializers/routing.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import Route from 'consul-ui/routing/route';
-
-export default {
- name: 'routing',
- initialize(application) {
- application.register('route:basic', Route);
- },
-};
diff --git a/ui/packages/consul-ui/app/instance-initializers/container.js b/ui/packages/consul-ui/app/instance-initializers/container.js
index 7745a90833..103038450b 100644
--- a/ui/packages/consul-ui/app/instance-initializers/container.js
+++ b/ui/packages/consul-ui/app/instance-initializers/container.js
@@ -1,8 +1,42 @@
import { runInDebug } from '@ember/debug';
+import require from 'require';
+import merge from 'deepmerge';
+const doc = document;
+const appName = 'consul-ui';
+const appNameJS = appName
+ .split('-')
+ .map((item, i) => (i ? `${item.substr(0, 1).toUpperCase()}${item.substr(1)}` : item))
+ .join('');
+
+export const services = merge.all(
+ [].concat(
+ ...[...doc.querySelectorAll(`script[data-${appName}-services]`)].map($item =>
+ JSON.parse($item.dataset[`${appNameJS}Services`])
+ )
+ )
+);
+
+const inject = function(container, obj) {
+ // inject all the things
+ Object.entries(obj).forEach(([key, value]) => {
+ switch(true) {
+ case (typeof value.class === 'string'):
+ if(require.has(value.class)) {
+ container.register(key.replace('auth-provider:', 'torii-provider:'), require(value.class).default);
+ } else {
+ throw new Error(`Unable to locate '${value.class}'`);
+ }
+ break;
+ }
+ });
+}
export default {
name: 'container',
initialize(application) {
+
+ inject(application, services);
+
const container = application.lookup('service:container');
// find all the services and add their classes to the container so we can
// look instances up by class afterwards as we then resolve the
diff --git a/ui/packages/consul-ui/app/locations/fsm-with-optional.js b/ui/packages/consul-ui/app/locations/fsm-with-optional.js
index 8027b65c18..5d5eeb19bd 100644
--- a/ui/packages/consul-ui/app/locations/fsm-with-optional.js
+++ b/ui/packages/consul-ui/app/locations/fsm-with-optional.js
@@ -199,15 +199,35 @@ export default class FSMWithOptionalLocation {
if (typeof this.router === 'undefined') {
this.router = this.container.lookup('router:main');
}
- const router = this.router._routerMicrolib;
- const url = router.generate(routeName, ...params, {
- queryParams: {},
- });
let withOptional = true;
switch (true) {
case routeName === 'settings':
case routeName.startsWith('docs.'):
withOptional = false;
+ break;
+ }
+ const router = this.router._routerMicrolib;
+ let url;
+ try {
+ url = router.generate(routeName, ...params, {
+ queryParams: {},
+ });
+ } catch(e) {
+ if(
+ !(this.router.currentRouteName.startsWith('docs') &&
+ e.message.startsWith('There is no route named ')
+ )
+ ) {
+ if(this.router.currentRouteName.startsWith('docs') && routeName.startsWith('dc')) {
+ params.unshift('dc-1');
+ url = router.generate(routeName, ...params, {
+ queryParams: {},
+ });
+ } else {
+ throw e;
+ }
+ }
+ return `console://${routeName} <= ${JSON.stringify(params)}`;
}
return this.formatURL(url, hash, withOptional);
}
@@ -217,6 +237,10 @@ export default class FSMWithOptionalLocation {
* performs an ember transition/refresh and browser location update using that
*/
transitionTo(url) {
+ if(this.router.currentRouteName.startsWith('docs') && url.startsWith('console://')) {
+ console.log(`location.transitionTo: ${url.substr(10)}`);
+ return true;
+ }
const transitionURL = this.getURLForTransition(url);
if (this._previousURL === transitionURL) {
// probably an optional parameter change
diff --git a/ui/packages/consul-ui/app/machines/validate.xstate.js b/ui/packages/consul-ui/app/machines/validate.xstate.js
new file mode 100644
index 0000000000..d4452d07d1
--- /dev/null
+++ b/ui/packages/consul-ui/app/machines/validate.xstate.js
@@ -0,0 +1,29 @@
+export default {
+ id: 'form',
+ initial: 'idle',
+ on: {
+ RESET: [
+ {
+ target: 'idle',
+ },
+ ],
+ },
+ states: {
+ idle: {
+ on: {
+ SUCCESS: [
+ {
+ target: 'success',
+ },
+ ],
+ ERROR: [
+ {
+ target: 'error',
+ },
+ ],
+ },
+ },
+ success: {},
+ error: {},
+ },
+};
diff --git a/ui/packages/consul-ui/app/models/partition.js b/ui/packages/consul-ui/app/models/partition.js
index 5a93857948..6e2ac85335 100644
--- a/ui/packages/consul-ui/app/models/partition.js
+++ b/ui/packages/consul-ui/app/models/partition.js
@@ -9,6 +9,8 @@ export default class PartitionModel extends Model {
@attr('string') uid;
@attr('string') Name;
@attr('string') Description;
+ // TODO: Is there some sort of date we can use here
+ @attr('string') DeletedAt;
@attr('string') Datacenter;
@attr('string') Namespace; // always ""
diff --git a/ui/packages/consul-ui/app/modifiers/disabled.js b/ui/packages/consul-ui/app/modifiers/disabled.js
index 2205a496a3..e3171496e8 100644
--- a/ui/packages/consul-ui/app/modifiers/disabled.js
+++ b/ui/packages/consul-ui/app/modifiers/disabled.js
@@ -3,15 +3,22 @@ import { modifier } from 'ember-modifier';
export default modifier(function enabled($element, [bool = true], hash) {
if (['input', 'textarea', 'select', 'button'].includes($element.nodeName.toLowerCase())) {
if (bool) {
- $element.disabled = bool;
+ $element.setAttribute('disabled', bool);
+ $element.setAttribute('aria-disabled', bool);
} else {
$element.dataset.disabled = false;
+ $element.removeAttribute('disabled');
+ $element.removeAttribute('aria-disabled');
}
return;
}
- for (const $el of $element.querySelectorAll('input,textarea')) {
- if ($el.dataset.disabled !== 'false') {
- $el.disabled = bool;
+ for (const $el of $element.querySelectorAll('input,textarea,button')) {
+ if(bool && $el.dataset.disabled !== 'false') {
+ $element.setAttribute('disabled', bool);
+ $element.setAttribute('aria-disabled', bool);
+ } else {
+ $element.removeAttribute('disabled');
+ $element.removeAttribute('aria-disabled');
}
}
});
diff --git a/ui/packages/consul-ui/app/modifiers/validate.js b/ui/packages/consul-ui/app/modifiers/validate.js
new file mode 100644
index 0000000000..5a74598b47
--- /dev/null
+++ b/ui/packages/consul-ui/app/modifiers/validate.js
@@ -0,0 +1,139 @@
+import Modifier from 'ember-modifier';
+import { action } from '@ember/object';
+
+class ValidationError extends Error {}
+
+export default class ValidateModifier extends Modifier {
+
+ item = null;
+ hash = null;
+
+ validate(value, validations = {}) {
+ if(Object.keys(validations).length === 0) {
+ return;
+ }
+ const errors = {};
+ Object.entries(this.hash.validations)
+ // filter out strings, for now these are helps, but ain't great if someone has a item.help
+ .filter(([key, value]) => typeof value !== 'string')
+ .forEach(([key, item]) => {
+ // optionally set things for you
+ if(this.item) {
+ this.item[key] = value;
+ }
+ (item || []).forEach((validation) => {
+ const re = new RegExp(validation.test);
+ if(!re.test(value)) {
+ errors[key] = new ValidationError(validation.error);
+ }
+ });
+ });
+ const state = this.hash.chart.state;
+ if(state.context == null) {
+ state.context = {};
+ }
+ if(Object.keys(errors).length > 0) {
+ state.context.errors = errors;
+ this.hash.chart.dispatch("ERROR");
+ } else {
+ state.context.errors = null;
+ this.hash.chart.dispatch("RESET");
+ }
+ }
+
+ @action
+ reset(e) {
+ if(e.target.value.length === 0) {
+ const state = this.hash.chart.state;
+ if(!state.context) {
+ state.context = {};
+ }
+ if(!state.context.errors) {
+ state.context.errors = {};
+ }
+ Object.entries(this.hash.validations)
+ // filter out strings, for now these are helps, but ain't great if someone has a item.help
+ .filter(([key, value]) => typeof value !== 'string')
+ .forEach(([key, item]) => {
+ if(typeof state.context.errors[key] !== 'undefined') {
+ delete state.context.errors[key];
+ }
+ });
+ if(Object.keys(state.context.errors).length === 0) {
+ state.context.errors = null;
+ this.hash.chart.dispatch("RESET");
+ }
+ }
+ }
+
+ async connect([value], _hash) {
+ this.element.addEventListener(
+ 'input',
+ this.listen
+ );
+ this.element.addEventListener(
+ 'blur',
+ this.reset
+ );
+ if(this.element.value.length > 0) {
+ await Promise.resolve();
+ if(this && this.element) {
+ this.validate(this.element.value, this.hash.validations);
+ }
+ }
+ }
+
+ @action
+ listen(e) {
+ this.validate(e.target.value, this.hash.validations);
+ }
+
+ disconnect() {
+ this.item = null;
+ this.hash = null;
+ this.element.removeEventListener(
+ 'input',
+ this.listen
+ )
+ this.element.removeEventListener(
+ 'blur',
+ this.reset
+ )
+ }
+
+
+ didReceiveArguments() {
+ const [value] = this.args.positional;
+ const _hash = this.args.named;
+
+ this.item = value;
+ this.hash = _hash;
+
+ if(typeof _hash.chart === 'undefined') {
+ this.hash.chart = {
+ state: {
+ context: {}
+ },
+ dispatch: (state) => {
+ switch(state) {
+ case 'ERROR':
+ _hash.onchange(this.hash.chart.state.context.errors);
+ break;
+ case 'RESET':
+ _hash.onchange();
+ break;
+ }
+ }
+ };
+
+ }
+ }
+
+ didInstall() {
+ this.connect(this.args.positional, this.args.named);
+ }
+
+ willRemove() {
+ this.disconnect();
+ }
+}
diff --git a/ui/packages/consul-ui/app/modifiers/validate.mdx b/ui/packages/consul-ui/app/modifiers/validate.mdx
new file mode 100644
index 0000000000..44969e6cd2
--- /dev/null
+++ b/ui/packages/consul-ui/app/modifiers/validate.mdx
@@ -0,0 +1,93 @@
+# validate
+
+Simple validation modifier to make it super easy to add validations to your
+form elements.
+
+**Please note:** You probably should be using one of our many (soon) Form
+Components like ` ` instead of using this. If you have something
+more custom that needs validation support, then read on!
+
+The `validate` modifier primarily requires a `validations` argument passing to
+it. The shape of this is an object containing property/validation pairs.
+Generally you will only need to pass one of these, and in this case the
+property is also used for naming any resulting errors. For example `Name` will
+result in `{Name: 'Name error message'}` being thrown/called/passed to the
+state's context or the `onchange` event.
+
+In the future we are looking to support validation based on other properties
+in the passed `item` positional argument, hence the slightly more complicated
+shape of this `validations` argument.
+
+Validation objects currently contain 2 properties: `test` and `error`. `test`
+is used to provide a Regular Expression used to validate the users' input, and
+the `error` property is a humanized string which is provided to the state's
+context/onchange event. We may add support for a `success` message in the
+future for when the validation is in a successful state.
+
+Please note: you should only need to use either the `chart` argument or the
+`onchange` listener, not both.
+
+
+```hbs preview-template
+
+{{#let
+
+ (hash
+ help='Must be a valid DNS hostname. Must contain 1-64 characters (numbers, letters, and hyphens), and must begin with a letter. Once created, this cannot be changed.'
+ Name=(array
+ (hash
+ test='^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}[a-zA-Z0-9])?$'
+ error='Name must be a valid DNS hostname.'
+ )
+ )
+ )
+
+as |validations|}}
+
+
+ Valid to begin with
+
+ {{#if this.validErrors.Name}}
+ {{this.validErrors.Name.message}}
+ {{/if}}
+
+
+
+ Invalid to begin with
+
+ {{#if this.invalidErrors.Name}}
+ {{this.invalidErrors.Name.message}}
+ {{/if}}
+
+
+{{/let}}
+```
+
+## Positional Arguments
+
+| Argument | Type | Default | Description |
+| --- | --- | --- | --- |
+| `item` | `object` | | An object containing properties to be validated |
+
+
+## Named Arguments
+
+| Argument | Type | Default | Description |
+| --- | --- | --- | --- |
+| `validations` | `object` | | Validation shaped object to use for the validation |
+| `onchange` | `object` | | A function called when the validations state changes form successful to erroneous and vice versa |
+| `chart` | `object` | | A statechart object following a state/dispatch interface to use as an alternative t onchange |
diff --git a/ui/packages/consul-ui/app/routing/application-debug.js b/ui/packages/consul-ui/app/routing/application-debug.js
new file mode 100644
index 0000000000..013dffa07d
--- /dev/null
+++ b/ui/packages/consul-ui/app/routing/application-debug.js
@@ -0,0 +1,27 @@
+import ApplicationRoute from '../routes/application';
+
+let isDebugRoute = false;
+const routeChange = function(transition) {
+ isDebugRoute = transition.to.name.startsWith('docs');
+};
+
+export default class DebugRoute extends ApplicationRoute {
+ constructor(owner) {
+ super(...arguments);
+ this.router = owner.lookup('service:router');
+ this.router.on('routeWillChange', routeChange);
+ }
+
+ renderTemplate() {
+ if (isDebugRoute) {
+ this.render('debug');
+ } else {
+ super.renderTemplate(...arguments);
+ }
+ }
+
+ willDestroy() {
+ this.router.off('routeWillChange', routeChange);
+ super.willDestroy(...arguments);
+ }
+}
diff --git a/ui/packages/consul-ui/app/routing/route.js b/ui/packages/consul-ui/app/routing/route.js
index b36b2c0a61..d621073b61 100644
--- a/ui/packages/consul-ui/app/routing/route.js
+++ b/ui/packages/consul-ui/app/routing/route.js
@@ -15,6 +15,22 @@ export default class BaseRoute extends Route {
@service('repository/permission') permissions;
@service('router') router;
+ _setRouteName() {
+ super._setRouteName(...arguments);
+ const routeName = this.routeName
+ .split('.')
+ .filter(item => item !== 'index')
+ .join('.');
+ const template = get(routes, `${routeName}._options.template`);
+ if(template) {
+ this.templateName = template;
+ }
+ const queryParams = get(routes, `${routeName}._options.queryParams`);
+ if(queryParams && this.routeName === 'dc.partitions.index') {
+ this.queryParams = queryParams;
+ }
+ }
+
redirect(model, transition) {
// remove any references to index as it is the same as the root routeName
const routeName = this.routeName
diff --git a/ui/packages/consul-ui/app/initializers/oidc-provider.js b/ui/packages/consul-ui/app/services/auth-providers/oauth2-code-with-url-provider.js
similarity index 63%
rename from ui/packages/consul-ui/app/initializers/oidc-provider.js
rename to ui/packages/consul-ui/app/services/auth-providers/oauth2-code-with-url-provider.js
index b86ad2405c..7924f3be07 100644
--- a/ui/packages/consul-ui/app/initializers/oidc-provider.js
+++ b/ui/packages/consul-ui/app/services/auth-providers/oauth2-code-with-url-provider.js
@@ -1,11 +1,13 @@
-import Oauth2CodeProvider from 'torii/providers/oauth2-code';
-const NAME = 'oidc-with-url';
-const Provider = Oauth2CodeProvider.extend({
- name: NAME,
- buildUrl: function() {
+import OAuth2CodeProvider from 'torii/providers/oauth2-code';
+export default class OAuth2CodeWithURLProvider extends OAuth2CodeProvider {
+
+ name = 'oidc-with-url';
+
+ buildUrl() {
return this.baseUrl;
- },
- open: function(options) {
+ }
+
+ open(options) {
const name = this.get('name'),
url = this.buildUrl(),
responseParams = ['state', 'code'],
@@ -20,18 +22,14 @@ const Provider = Oauth2CodeProvider.extend({
provider: name,
};
});
- },
- close: function() {
+ }
+
+ close() {
const popup = this.get('popup.remote') || {};
if (typeof popup.close === 'function') {
return popup.close();
}
- },
-});
-export function initialize(application) {
- application.register(`torii-provider:${NAME}`, Provider);
+ }
+
}
-export default {
- initialize,
-};
diff --git a/ui/packages/consul-ui/app/services/data-sink/protocols/http.js b/ui/packages/consul-ui/app/services/data-sink/protocols/http.js
index c6a293c2d8..4a44710a80 100644
--- a/ui/packages/consul-ui/app/services/data-sink/protocols/http.js
+++ b/ui/packages/consul-ui/app/services/data-sink/protocols/http.js
@@ -5,6 +5,7 @@ export default class HttpService extends Service {
@service('settings') settings;
@service('repository/intention') intention;
@service('repository/kv') kv;
+ @service('repository/partition') partition;
@service('repository/session') session;
prepare(sink, data, instance) {
diff --git a/ui/packages/consul-ui/app/services/form.js b/ui/packages/consul-ui/app/services/form.js
index 80cff14f3c..faec09a6e8 100644
--- a/ui/packages/consul-ui/app/services/form.js
+++ b/ui/packages/consul-ui/app/services/form.js
@@ -7,6 +7,7 @@ import policy from 'consul-ui/forms/policy';
import role from 'consul-ui/forms/role';
import intention from 'consul-ui/forms/intention';
import nspace from 'consul-ui/forms/nspace';
+import partition from 'consul-ui/forms/partition';
const builder = builderFactory();
@@ -17,6 +18,7 @@ const forms = {
role: role,
intention: intention,
nspace: nspace,
+ partition: partition,
};
export default class FormService extends Service {
diff --git a/ui/packages/consul-ui/app/instance-initializers/debug.js b/ui/packages/consul-ui/app/services/i18n-debug.js
similarity index 63%
rename from ui/packages/consul-ui/app/instance-initializers/debug.js
rename to ui/packages/consul-ui/app/services/i18n-debug.js
index a3f429aa2b..06c19c9594 100644
--- a/ui/packages/consul-ui/app/instance-initializers/debug.js
+++ b/ui/packages/consul-ui/app/services/i18n-debug.js
@@ -1,39 +1,13 @@
-import ApplicationRoute from '../routes/application';
-import { I18nService, formatOptionsSymbol } from './i18n';
+import I18nService, { formatOptionsSymbol } from 'consul-ui/services/i18n';
import ucfirst from 'consul-ui/utils/ucfirst';
import faker from 'faker';
-let isDebugRoute = false;
-const routeChange = function(transition) {
- isDebugRoute = transition.to.name.startsWith('docs');
-};
-const DebugRoute = class extends ApplicationRoute {
- constructor(owner) {
- super(...arguments);
- this.router = owner.lookup('service:router');
- this.router.on('routeWillChange', routeChange);
- }
-
- renderTemplate() {
- if (isDebugRoute) {
- this.render('debug');
- } else {
- super.renderTemplate(...arguments);
- }
- }
-
- willDestroy() {
- this.router.off('routeWillChange', routeChange);
- super.willDestroy(...arguments);
- }
-};
-
// we currently use HTML in translations, so anything 'word-like' with these
// chars won't get translated
const translator = cb => item => (!['<', '>', '='].includes(item) ? cb(item) : item);
-class DebugI18nService extends I18nService {
+export default class DebugI18nService extends I18nService {
formatMessage(value, formatOptions) {
const text = super.formatMessage(...arguments);
let locale = this.env.var('CONSUL_INTL_LOCALE');
@@ -82,11 +56,4 @@ class DebugI18nService extends I18nService {
return formatOptions;
}
}
-export default {
- name: 'debug',
- after: 'i18n',
- initialize(application) {
- application.register('route:application', DebugRoute);
- application.register('service:intl', DebugI18nService);
- },
-};
+
diff --git a/ui/packages/consul-ui/app/instance-initializers/i18n.js b/ui/packages/consul-ui/app/services/i18n.js
similarity index 83%
rename from ui/packages/consul-ui/app/instance-initializers/i18n.js
rename to ui/packages/consul-ui/app/services/i18n.js
index 601fc32d2c..94f05abfd9 100644
--- a/ui/packages/consul-ui/app/instance-initializers/i18n.js
+++ b/ui/packages/consul-ui/app/services/i18n.js
@@ -2,7 +2,7 @@ import IntlService from 'ember-intl/services/intl';
import { inject as service } from '@ember/service';
export const formatOptionsSymbol = Symbol();
-export class I18nService extends IntlService {
+export default class I18nService extends IntlService {
@service('env') env;
/**
* Additionally injects selected project level environment variables into the
@@ -30,9 +30,3 @@ export class I18nService extends IntlService {
};
}
}
-export default {
- name: 'i18n',
- initialize: function(container) {
- container.register('service:intl', I18nService);
- },
-};
diff --git a/ui/packages/consul-ui/app/services/repository.js b/ui/packages/consul-ui/app/services/repository.js
index f7e9810e0c..8fa0f9378c 100644
--- a/ui/packages/consul-ui/app/services/repository.js
+++ b/ui/packages/consul-ui/app/services/repository.js
@@ -6,6 +6,27 @@ import { isChangeset } from 'validated-changeset';
import HTTPError from 'consul-ui/utils/http/error';
import { ACCESS_READ } from 'consul-ui/abilities/base';
+export const softDelete = (repo, item) => {
+ // Some deletes need to be more of a soft delete.
+ // Therefore the partition still exists once we've requested a delete/removal.
+ // This makes 'removing' more of a custom action rather than a standard
+ // ember-data delete.
+ // Here we use the same request for a delete but we bypass ember-data's
+ // destroyRecord/unloadRecord and serialization so we don't get
+ // ember data error messages when the UI tries to update a 'DeletedAt' property
+ // on an object that ember-data is trying to delete
+ const res = repo.store.adapterFor(repo.getModelName()).rpc(
+ (adapter, request, serialized, unserialized) => {
+ return adapter.requestForDeleteRecord(request, serialized, unserialized);
+ },
+ (serializer, respond, serialized, unserialized) => {
+ return item;
+ },
+ item,
+ repo.getModelName()
+ );
+ return res;
+}
export default class RepositoryService extends Service {
@service('store') store;
@service('env') env;
diff --git a/ui/packages/consul-ui/app/services/repository/nspace.js b/ui/packages/consul-ui/app/services/repository/nspace.js
index 4728084168..0327c48109 100644
--- a/ui/packages/consul-ui/app/services/repository/nspace.js
+++ b/ui/packages/consul-ui/app/services/repository/nspace.js
@@ -1,6 +1,6 @@
import { inject as service } from '@ember/service';
import { runInDebug } from '@ember/debug';
-import RepositoryService from 'consul-ui/services/repository';
+import RepositoryService, { softDelete } from 'consul-ui/services/repository';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/nspace';
import dataSource from 'consul-ui/decorators/data-source';
@@ -75,25 +75,7 @@ export default class NspaceEnabledService extends RepositoryService {
}
remove(item) {
- // Namespace deletion is more of a soft delete.
- // Therefore the namespace still exists once we've requested a delete/removal.
- // This makes 'removing' more of a custom action rather than a standard
- // ember-data delete.
- // Here we use the same request for a delete but we bypass ember-data's
- // destroyRecord/unloadRecord and serialization so we don't get
- // ember data error messages when the UI tries to update a 'DeletedAt' property
- // on an object that ember-data is trying to delete
- const res = this.store.adapterFor('nspace').rpc(
- (adapter, request, serialized, unserialized) => {
- return adapter.requestForDeleteRecord(request, serialized, unserialized);
- },
- (serializer, respond, serialized, unserialized) => {
- return item;
- },
- item,
- 'nspace'
- );
- return res;
+ return softDelete(this, item);
}
authorize(dc, nspace) {
diff --git a/ui/packages/consul-ui/app/services/repository/partition.js b/ui/packages/consul-ui/app/services/repository/partition.js
index 2832466e5c..002db6f385 100644
--- a/ui/packages/consul-ui/app/services/repository/partition.js
+++ b/ui/packages/consul-ui/app/services/repository/partition.js
@@ -1,6 +1,6 @@
import { inject as service } from '@ember/service';
import { runInDebug } from '@ember/debug';
-import RepositoryService from 'consul-ui/services/repository';
+import RepositoryService, { softDelete } from 'consul-ui/services/repository';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/partition';
import dataSource from 'consul-ui/decorators/data-source';
@@ -27,6 +27,7 @@ const findActive = function(items, item) {
const MODEL_NAME = 'partition';
export default class PartitionRepository extends RepositoryService {
@service('settings') settings;
+ @service('form') form;
@service('repository/permission') permissions;
getModelName() {
@@ -49,9 +50,25 @@ export default class PartitionRepository extends RepositoryService {
return super.findAll(...arguments).catch(() => []);
}
- @dataSource('/:ns/:dc/partition/:id')
- async findBySlug() {
- return super.findBySlug(...arguments);
+ @dataSource('/:partition/:ns/:dc/partition/:id')
+ async findBySlug(params) {
+ let item;
+ if (params.id === '') {
+ item = await this.create({
+ Datacenter: params.dc,
+ Partition: '',
+ });
+ } else {
+ item = await super.findBySlug(...arguments);
+ }
+ return this.form
+ .form(this.getModelName())
+ .setData(item)
+ .getData();
+ }
+
+ remove(item) {
+ return softDelete(this, item);
}
async getActive(currentName = '') {
diff --git a/ui/packages/consul-ui/app/services/state-with-charts.js b/ui/packages/consul-ui/app/services/state-with-charts.js
new file mode 100644
index 0000000000..31e26716ab
--- /dev/null
+++ b/ui/packages/consul-ui/app/services/state-with-charts.js
@@ -0,0 +1,10 @@
+import StateService from 'consul-ui/services/state';
+
+import validate from 'consul-ui/machines/validate.xstate';
+
+export default class ChartedStateService extends StateService {
+ stateCharts = {
+ 'validate': validate
+ };
+}
+
diff --git a/ui/packages/consul-ui/app/services/state.js b/ui/packages/consul-ui/app/services/state.js
index 6e9d612cd4..76a98bef48 100644
--- a/ui/packages/consul-ui/app/services/state.js
+++ b/ui/packages/consul-ui/app/services/state.js
@@ -4,14 +4,20 @@ import flat from 'flat';
import { createMachine, interpret } from '@xstate/fsm';
export default class StateService extends Service {
- @service('logger')
- logger;
+
+ stateCharts = {};
+
+ @service('logger') logger;
// @xstate/fsm
log(chart, state) {
// this.logger.execute(`${chart.id} > ${state.value}`);
}
+ stateChart(name) {
+ return this.stateCharts[name];
+ }
+
addGuards(chart, options) {
this.guards(chart).forEach(function([path, name]) {
// xstate/fsm has no guard lookup
diff --git a/ui/packages/consul-ui/app/sort/comparators/partition.js b/ui/packages/consul-ui/app/sort/comparators/partition.js
new file mode 100644
index 0000000000..1ec2b1a99c
--- /dev/null
+++ b/ui/packages/consul-ui/app/sort/comparators/partition.js
@@ -0,0 +1,3 @@
+export default ({ properties }) => key => {
+ return properties(['Name'])(key);
+};
diff --git a/ui/packages/consul-ui/app/styles/debug.scss b/ui/packages/consul-ui/app/styles/debug.scss
index 6fe7aae1a4..00806a4279 100644
--- a/ui/packages/consul-ui/app/styles/debug.scss
+++ b/ui/packages/consul-ui/app/styles/debug.scss
@@ -13,14 +13,33 @@
html.is-debug body > .brand-loader {
display: none !important;
}
+html.is-debug [class*='partition-'] {
+ display: block !important;
+}
html:not(.with-data-source) .data-source-debug {
display: none;
}
+html:not(.with-data-source) .data-source-debug {
+ display: none;
+}
+%debug-box {
+ color: red;
+ background-color: rgb(255 255 255 / 70%);
+ position: absolute;
+ /* hi */
+ z-index: 100000;
+}
+html.with-href-to [href^='console://']::before {
+ @extend %p3;
+ @extend %debug-box;
+ content: attr(href);
+ display: inline;
+}
html.with-route-announcer .route-title {
@extend %unvisually-hidden;
}
.data-source-debug {
- color: red;
+ @extend %debug-box;
}
.data-source-debug input:checked + pre code::after {
content: attr(data-json);
diff --git a/ui/packages/consul-ui/ember-cli-build.js b/ui/packages/consul-ui/ember-cli-build.js
index 0ede17347c..e7259702fc 100644
--- a/ui/packages/consul-ui/ember-cli-build.js
+++ b/ui/packages/consul-ui/ember-cli-build.js
@@ -1,8 +1,15 @@
'use strict';
+const path = require('path');
+const exists = require('fs').existsSync;
+
const Funnel = require('broccoli-funnel');
+const mergeTrees = require('broccoli-merge-trees');
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
const utils = require('./config/utils');
+// const BroccoliDebug = require('broccoli-debug');
+// const debug = BroccoliDebug.buildDebugCallback(`app:consul-ui`)
+
module.exports = function(defaults, $ = process.env) {
// available environments
// ['production', 'development', 'staging', 'test'];
@@ -10,6 +17,7 @@ module.exports = function(defaults, $ = process.env) {
$ = utils.env($);
const env = EmberApp.env();
const prodlike = ['production', 'staging'];
+ const devlike = ['development', 'staging'];
const sourcemaps = !['production'].includes(env) && !$('BABEL_DISABLE_SOURCEMAPS', false);
const trees = {};
@@ -17,6 +25,16 @@ module.exports = function(defaults, $ = process.env) {
const outputPaths = {};
let excludeFiles = [];
+ const apps = [
+ 'consul-acls',
+ 'consul-partitions'
+ ].map(item => {
+ return {
+ name: item,
+ path: path.dirname(require.resolve(`${item}/package.json`))
+ };
+ });
+
const babel = {
plugins: [
'@babel/plugin-proposal-object-rest-spread',
@@ -29,6 +47,7 @@ module.exports = function(defaults, $ = process.env) {
// exclude any component/pageobject.js files from anything but test
excludeFiles = excludeFiles.concat([
'components/**/pageobject.js',
+ 'components/**/test-support.js',
'components/**/*.test-support.js',
'components/**/*.test.js',
])
@@ -38,6 +57,8 @@ module.exports = function(defaults, $ = process.env) {
// exclude our debug initializer, route and template
excludeFiles = excludeFiles.concat([
'instance-initializers/debug.js',
+ 'routing/**/*-debug.js',
+ 'services/**/*-debug.js',
'templates/debug.hbs',
'components/debug/**/*.*'
])
@@ -61,11 +82,21 @@ module.exports = function(defaults, $ = process.env) {
['strip-function-call', {'strip': ['Ember.runInDebug']}]
)
}
- //
- trees.app = new Funnel('app', {
- exclude: excludeFiles
+ //
+ trees.app = mergeTrees([
+ new Funnel('app', { exclude: excludeFiles })
+ ].concat(
+ apps.filter(item => exists(`${item.path}/app`)).map(item => new Funnel(`${item.path}/app`, {exclude: excludeFiles}))
+ ), {
+ overwrite: true
});
+ trees.vendor = mergeTrees([
+ new Funnel('vendor'),
+ ].concat(
+ apps.map(item => new Funnel(`${item.path}/vendor`))
+ ));
+ //
const app = new EmberApp(
Object.assign({}, defaults, {
@@ -112,6 +143,16 @@ module.exports = function(defaults, $ = process.env) {
},
}
);
+ apps.forEach(item => {
+ app.import(`vendor/${item.name}/routes.js`, {
+ outputFile: `assets/${item.name}/routes.js`,
+ });
+ });
+ ['consul-ui/services'].concat(devlike ? ['consul-ui/services-debug'] : []).forEach(item => {
+ app.import(`vendor/${item}.js`, {
+ outputFile: `assets/${item}.js`,
+ });
+ });
// Use `app.import` to add additional libraries to the generated
// output files.
//
@@ -163,9 +204,6 @@ module.exports = function(defaults, $ = process.env) {
app.import('vendor/metrics-providers/prometheus.js', {
outputFile: 'assets/metrics-providers/prometheus.js',
});
- app.import('vendor/acls/routes.js', {
- outputFile: 'assets/acls/routes.js',
- });
app.import('vendor/init.js', {
outputFile: 'assets/init.js',
});
diff --git a/ui/packages/consul-ui/lib/startup/templates/body.html.js b/ui/packages/consul-ui/lib/startup/templates/body.html.js
index cafa6fb7c6..935a4257c5 100644
--- a/ui/packages/consul-ui/lib/startup/templates/body.html.js
+++ b/ui/packages/consul-ui/lib/startup/templates/body.html.js
@@ -41,23 +41,45 @@ ${environment === 'production' ? `{{jsonEncode .}}` : JSON.stringify(config.oper
"codemirror/mode/yaml/yaml.js": "${rootURL}assets/codemirror/mode/yaml/yaml.js"
}
+
+${
+ environment === 'development' || environment === 'staging'
+ ? `
+
+` : ``}
${
environment === 'production'
? `
{{if .ACLsEnabled}}
-
+
+{{end}}
+{{if .PartitionsEnabled}}
+
{{end}}
`
: `
`
}
diff --git a/ui/packages/consul-ui/mock-api/v1/partition/_ b/ui/packages/consul-ui/mock-api/v1/partition/_
new file mode 100644
index 0000000000..f8ad7132c9
--- /dev/null
+++ b/ui/packages/consul-ui/mock-api/v1/partition/_
@@ -0,0 +1,6 @@
+{
+ "Name": "${location.pathname.get(2)}",
+ "Description": "${fake.lorem.sentence()}",
+ "CreateIndex": 12,
+ "ModifyIndex": 16
+}
diff --git a/ui/packages/consul-ui/package.json b/ui/packages/consul-ui/package.json
index a4e9383c36..5794a92f3a 100644
--- a/ui/packages/consul-ui/package.json
+++ b/ui/packages/consul-ui/package.json
@@ -74,10 +74,13 @@
"babel-plugin-strip-function-call": "^1.0.2",
"base64-js": "^1.3.0",
"broccoli-asset-rev": "^3.0.0",
+ "broccoli-debug": "^0.6.5",
"broccoli-funnel": "^3.0.3",
"broccoli-merge-trees": "^4.2.0",
"chalk": "^4.1.0",
"clipboard": "^2.0.4",
+ "consul-acls": "*",
+ "consul-partitions": "*",
"css.escape": "^1.5.1",
"d3-array": "^2.8.0",
"d3-scale": "^3.2.3",
diff --git a/ui/packages/consul-ui/vendor/acls/routes.js b/ui/packages/consul-ui/vendor/acls/routes.js
deleted file mode 100644
index b553c9e93d..0000000000
--- a/ui/packages/consul-ui/vendor/acls/routes.js
+++ /dev/null
@@ -1,15 +0,0 @@
-(function(appNameJS = 'consulUi', doc = document) {
- const scripts = doc.getElementsByTagName('script');
- const script = scripts[scripts.length - 1];
- script.dataset[`${appNameJS}Routes`] = JSON.stringify({
- dc: {
- acls: {
- tokens: {
- _options: {
- abilities: ['read tokens'],
- },
- },
- },
- },
- });
-})();
diff --git a/ui/packages/consul-ui/vendor/consul-ui/services-debug.js b/ui/packages/consul-ui/vendor/consul-ui/services-debug.js
new file mode 100644
index 0000000000..dba5d8f3ed
--- /dev/null
+++ b/ui/packages/consul-ui/vendor/consul-ui/services-debug.js
@@ -0,0 +1,15 @@
+(services => services({
+ "route:application": {
+ "class": "consul-ui/routing/application-debug"
+ },
+ "service:intl": {
+ "class": "consul-ui/services/i18n-debug"
+ }
+}))(
+ (json, data = document.currentScript.dataset) => {
+ const appNameJS = data.appName.split('-')
+ .map((item, i) => i ? `${item.substr(0, 1).toUpperCase()}${item.substr(1)}` : item)
+ .join('');
+ data[`${appNameJS}Services`] = JSON.stringify(json);
+ }
+);
diff --git a/ui/packages/consul-ui/vendor/consul-ui/services.js b/ui/packages/consul-ui/vendor/consul-ui/services.js
new file mode 100644
index 0000000000..61b50b6d11
--- /dev/null
+++ b/ui/packages/consul-ui/vendor/consul-ui/services.js
@@ -0,0 +1,21 @@
+(services => services({
+ "route:basic": {
+ "class": "consul-ui/routing/route"
+ },
+ "service:intl": {
+ "class": "consul-ui/services/i18n"
+ },
+ "service:state": {
+ "class": "consul-ui/services/state-with-charts"
+ },
+ "auth-provider:oidc-with-url": {
+ "class": "consul-ui/services/auth-providers/oauth2-code-with-url-provider"
+ }
+}))(
+ (json, data = document.currentScript.dataset) => {
+ const appNameJS = data.appName.split('-')
+ .map((item, i) => i ? `${item.substr(0, 1).toUpperCase()}${item.substr(1)}` : item)
+ .join('');
+ data[`${appNameJS}Services`] = JSON.stringify(json);
+ }
+);