ui: Adds initial CRUD for partitions (#11188)

* Add `is` and `test` helpers in a similar vein to `can`

Adds 2 new helpers in a similar vein to ember-cans can:

- `is` allows you to use vocab/phrases such as (is "something model") which calls isSomething() on the models ability.
- `test` allows you to use vocab/phrases such as (test "is something model") or (test "can something model")which calls isSomething() / canSomething() on the models ability. Mostly using the is helper and the can helper. It's basically the is/can helper combined.

* Adds TextInput component + related modifiers/helpers/machines/services (#11189)

Adds a few new components/modifiers/helpers to aid building forms.

- state-chart helper, used in lieu of a more generic approach for requiring our statecharts.
- A few modifications to our existing disabled modifier.
- A new 'validation' modifier, a super small form validation approach built to make use of state charts (optionally). Eventually we should be able to replace our current validation approach (ember-changeset-validations + extra deps) with this.
- A new TextInput component, which is the first of our new components specifically to make it easy to build forms with validations. This is still a WIP, I left some comments in pointing out where this one would be progressed, but as we don't need the planned functionality yet, I left it where it was. All of this will be fleshed out more at a later date.

Documentation is included for all of ^

* ui: Adds initial CRUD for partitions (#11190)

Adds basic CRUD support for partitions. Engineering-wise probably the biggest takeaway here is that we needed to write very little javascript code to add this entire feature, and the little javascript we did need to write was very straightforwards. Everything is pretty much just HTML. Another note to make is that both ember-changeset and ember-data (model layer things) are now completely abstracted away from the view layer of the application.

New components:

- Consul::Partition::Form
- Consul::Partition::List
- Consul::Partition::Notifications
- Consul::Partition::SearchBar
- Consul::Partition::Selector

See additional documentation here for more details

New Route templates:

- index.hbs partition listing/searching/filtering
- edit.hbs partition editing and creation

Additionally:

There is some additional debug work here for better observability and to prevent any errors regarding our href-to usage when a dc is not available in our documentation site.

Our softDelete functionality has been DRYed out a little to be used across two repos.

isLinkable was removed from our ListCollection component for lists like upstream and service listing, and instead use our new is helper from within the ListCollection, meaning we've added a few more lighterweight templateOnly components.

* ui: Exclude all debug-like files from the build (#11211)

This PR adds **/*-debug.* to our test/prod excluded files (realised I needed to add test-support.js also so added that here as its more or less the same thing). Conditionally juggling ES6 static imports (specifically debug ones) for this was also getting a little hairy, so I moved it all to use the same approach as our conditional routes. All in all it brings the vendor build back down to ~430kb gzipped.
This commit is contained in:
John Cowen 2021-10-08 16:29:30 +01:00 committed by GitHub
parent 6c6c75707c
commit baa377ddca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 1711 additions and 226 deletions

3
.changelog/11188.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: Added initial support for admin partition CRUD
```

View File

@ -22,7 +22,7 @@ references:
test-results: &TEST_RESULTS_DIR /tmp/test-results test-results: &TEST_RESULTS_DIR /tmp/test-results
cache: 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" }} rubygem: &RUBYGEM_CACHE_KEY static-site-gems-v1-{{ checksum "Gemfile.lock" }}
environment: &ENVIRONMENT environment: &ENVIRONMENT
@ -602,7 +602,7 @@ jobs:
- run: - run:
name: install yarn packages name: install yarn packages
command: cd ui && yarn install command: cd ui && yarn install && cd packages/consul-ui && yarn install
- save_cache: - save_cache:
key: *YARN_CACHE_KEY key: *YARN_CACHE_KEY

View File

@ -11,7 +11,7 @@
"scripts": { "scripts": {
"doc:toc": "doctoc README.md", "doc:toc": "doctoc README.md",
"compliance": "npm-run-all compliance:*", "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": { "devDependencies": {

View File

@ -0,0 +1,5 @@
{
"name": "consul-acls",
"version": "0.1.0",
"private": true
}

View File

@ -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);
}
);

View File

@ -0,0 +1,24 @@
# Consul::Partition::Form
```hbs preview-template
<DataLoader @src={{
uri '/${partition}/${nspace}/${dc}/partition/${id}'
(hash
partition='partition'
nspace='nspace'
dc='dc'
id=''
)
}}
as |loader|>
<BlockSlot @name="loaded">
<Consul::Partition::Form
@item={{loader.data}}
@dc={{'dc-1'}}
@nspace={{'nspace'}}
@partition={{'partition'}}
@onsubmit={{noop}}
/>
</BlockSlot>
</DataLoader>
```

View File

@ -0,0 +1,126 @@
<div
class="consul-partition-form"
...attributes
>
<DataWriter
@sink={{uri
'/${partition}/${nspace}/${dc}/partition'
(hash
partition=''
nspace=''
dc=@item.Datacenter
)
}}
@type={{'partition'}}
@label={{label}}
@ondelete={{fn (if @ondelete @ondelete @onsubmit) @item}}
@onchange={{fn (optional @onsubmit) @item}}
as |writer|>
<BlockSlot @name="content">
{{#let
@item
(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.'
)
)
)
(hash
Description=(array)
)
as |item Name Description|}}
<form
{{on 'submit' (fn writer.persist item)}}
{{disabled (not (can "write partition" item=item))}}
>
<StateChart
@src={{state-chart 'validate'}}
as |State Guard Action dispatch state|>
<fieldset>
{{#if (is "new partition" item=item)}}
<TextInput
@name="Name"
@placeholder="Name"
@item={{item}}
@validations={{Name}}
@chart={{hash
state=state
dispatch=dispatch
}}
/>
{{/if}}
<TextInput
@expanded={{true}}
@name="Description"
@label="Description (Optional)"
@item={{item}}
@validations={{Description}}
@chart={{hash
state=state
dispatch=dispatch
}}
/>
</fieldset>
<div>
{{#if (and (is "new partition" item=item) (can "create partitions")) }}
<button
type="submit"
{{disabled (or (is "pristine partition" item=item) (state-matches state "error"))}}
>
Save
</button>
{{else if (can "write partition" item=item)}}
<button type="submit">Save</button>
{{/if}}
<button
type="reset"
{{on 'click' (if @oncancel (fn @oncancel item) (fn @onsubmit item))}}
>
Cancel
</button>
{{#if (and (not (is "new partition" item=item)) (can "delete partition" item=item))}}
<ConfirmationDialog @message="Are you sure you want to delete this Partition?">
<BlockSlot @name="action" as |confirm|>
<button
data-test-delete
type="button"
class="type-delete"
{{on 'click' (fn confirm (fn writer.delete item))}}
>
Delete
</button>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<DeleteConfirmation
@message={{message}}
@execute={{execute}}
@cancel={{cancel}}
/>
</BlockSlot>
</ConfirmationDialog>
{{/if}}
</div>
</StateChart>
</form>
{{/let}}
</BlockSlot>
</DataWriter>
</div>

View File

@ -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
<DataSource @src={{uri '/partition/default/dc-1/partitions'}} as |source|>
<Consul::Partition::List
@items={{source.data}}
@ondelete={{noop}}
/>
</DataSource>
```
### 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)
---

View File

@ -0,0 +1,63 @@
<ListCollection
class="consul-partition-list"
...attributes
@items={{@items}}
@linkable="linkable partition"
as |item|>
<BlockSlot @name="header">
{{#if item.DeletedAt}}
<p>
Deleting {{item.Name}}...
</p>
{{else}}
<a data-test-partition={{item.Name}} href={{href-to 'dc.partitions.edit' item.Name}}>{{item.Name}}</a>
{{/if}}
</BlockSlot>
<BlockSlot @name="details">
{{#if item.Description}}
<dl>
<dt>Description</dt>
<dd data-test-description>
{{item.Description}}
</dd>
</dl>
{{/if}}
</BlockSlot>
<BlockSlot @name="actions" as |Actions|>
{{#if (not item.DeletedAt)}}
<Actions as |Action|>
<Action data-test-edit-action @href={{href-to 'dc.partitions.edit' item.Name}}>
<BlockSlot @name="label">
{{#if (can "write partition" item=item)}}
Edit
{{else}}
View
{{/if}}
</BlockSlot>
</Action>
{{#if (can "delete partition" item=item)}}
<Action data-test-delete-action @onclick={{action @ondelete item}} class="dangerous">
<BlockSlot @name="label">
Delete
</BlockSlot>
<BlockSlot @name="confirmation" as |Confirmation|>
<Confirmation class="warning">
<BlockSlot @name="header">
Confirm delete
</BlockSlot>
<BlockSlot @name="body">
<p>
Are you sure you want to delete this partition?
</p>
</BlockSlot>
<BlockSlot @name="confirm" as |Confirm|>
<Confirm>Delete</Confirm>
</BlockSlot>
</Confirmation>
</BlockSlot>
</Action>
{{/if}}
</Actions>
{{/if}}
</BlockSlot>
</ListCollection>

View File

@ -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']),
});
};

View File

@ -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
<figure>
<figcaption>Provide a widget to change the <code>@type</code></figcaption>
<select
{{on 'change' (action (mut this.type) value="target.value")}}
>
<option>create</option>
<option>update</option>
<option>delete</option>
</select>
</figure>
<figure>
<figcaption>Provide a widget to change the <code>@status</code></figcaption>
<select
{{on 'change' (action (mut this.success) value="target.value")}}
>
<option>success</option>
<option>error</option>
</select>
</figure>
<figure>
<figcaption>Show the notification text</figcaption>
<p>
<Consul::Partition::Notifications
@type={{or this.type 'create'}}
@status={{or this.success 'success'}}
@error={{undefined}}
/>
</p>
</figure>
```
## See
- [Template Source Code](./index.hbs)
---

View File

@ -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 }}
<br />{{concat '(' (if error.status (concat error.status ': ')) error.detail ')'}}
{{/if}}
{{/let}}

View File

@ -0,0 +1,30 @@
# Consul::Partition::SearchBar
Searchbar tailored for searching Partitions. Follows our more generic
'*::SearchBar' component interface.
```hbs preview-template
<Consul::Partition::SearchBar
@search={{this.search}}
@onsearch={{fn (mut this.search) value="target.value"}}
@sort={{hash
value='Name:asc'
change=(noop)
}}
@filter={{hash
searchproperty=(hash
value=(array)
change=(noop)
default=(array)
)
}}
/>
```
## See
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,98 @@
<SearchBar
class="consul-partition-search-bar"
...attributes
@filter={{@filter}}
>
<: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|}}
<search.RemoveFilter
aria-label={{t "common.ui.remove" item=(concat key " " value)}}
>
<dl>
<dt>{{key}}</dt>
<dd>{{value}}</dd>
</dl>
</search.RemoveFilter>
{{/let}}
</:status>
<:search as |search|>
<search.Search
@onsearch={{action @onsearch}}
@value={{@search}}
@placeholder={{t "common.search.search"}}
>
<search.Select
class="type-search-properties"
@position="right"
@onchange={{action @filter.searchproperty.change}}
@multiple={{true}}
@required={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
{{t "common.search.searchproperty"}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each @filter.searchproperty.default as |prop|}}
<Option @value={{prop}} @selected={{contains prop @filter.searchproperty.value}}>
{{t (concat "common.consul." (lowercase prop))}}
</Option>
{{/each}}
{{/let}}
</BlockSlot>
</search.Select>
</search.Search>
</:search>
<:sort as |search|>
<search.Select
class="type-sort"
data-test-sort-control
@position="right"
@onchange={{action @sort.change}}
@multiple={{false}}
@required={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
{{#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}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Optgroup @label={{t "common.consul.name"}}>
<Option @value="Name:asc" @selected={{eq "Name:asc" @sort.value}}>{{t "common.sort.alpha.asc"}}</Option>
<Option @value="Name:desc" @selected={{eq "Name:desc" @sort.value}}>{{t "common.sort.alpha.desc"}}</Option>
</Optgroup>
{{/let}}
</BlockSlot>
</search.Select>
</:sort>
</SearchBar>

View File

@ -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 `<ul>` element.
- For the moment, make sure you have enabled partitions using developer debug
cookies.
```hbs preview-template
<ul>
<Consul::Partition::Selector
@dc={{hash
Name='dc-1'
}}
@nspace='default'
@partition='default'
@partitions={{or this.partitions (array)}}
@onchange={{action (mut this.partitions) value="data"}}
/>
</ul>
```
## 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)
---

View File

@ -0,0 +1,53 @@
{{#if (can "choose partitions")}}
<li
class="partitions"
data-test-partition-menu
>
<PopoverMenu
aria-label="Admin Partition"
@position="left"
as |components api|>
<BlockSlot @name="trigger">
{{@partition}}
</BlockSlot>
<BlockSlot @name="menu">
{{#let components.MenuItem components.MenuSeparator as |MenuItem MenuSeparator|}}
<DataSource
@src={{uri
'/*/*/${dc}/partitions'
(hash
dc=@dc.Name
)
}}
@onchange={{fn (optional @onchange)}}
/>
{{#each (reject-by 'DeletedAt' @partitions) as |item|}}
<MenuItem
class={{if (eq @partition item.Name) 'is-active'}}
@href={{href-to '.' params=(hash
partition=item.Name
nspace=(if (gt @nspace.length 0) @nspace undefined)
)}}
>
<BlockSlot @name="label">
{{item.Name}}
</BlockSlot>
</MenuItem>
{{/each}}
{{#if (can 'manage partitions')}}
<MenuSeparator />
<MenuItem
data-test-main-nav-partitions
@href={{href-to 'dc.partitions.index' @dc.Name}}
>
<BlockSlot @name="label">
Manage Admin Partitions
</BlockSlot>
</MenuItem>
{{/if}}
{{/let}}
</BlockSlot>
</PopoverMenu>
</li>
{{/if}}

View File

@ -0,0 +1,68 @@
<Route
@name={{routeName}}
as |route|>
<DataLoader @src={{
uri '/${partition}/${nspace}/${dc}/partition/${id}'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
id=(or route.params.name '')
)
}}
as |loader|>
<BlockSlot @name="error">
<AppError
@error={{loader.error}}
@login={{route.model.app.login.open}}
/>
</BlockSlot>
<BlockSlot @name="loaded">
{{#let
route.params.dc
route.params.partition
route.params.nspace
loader.data
loader.data.isNew
as |dc partition nspace item create|}}
<AppView>
<BlockSlot @name="notification" as |status type item error|>
<Consul::Partition::Notifications
@type={{type}}
@status={{status}}
@error={{error}}
/>
</BlockSlot>
<BlockSlot @name="breadcrumbs">
<ol>
<li><a data-test-back href={{href-to 'dc.partitions'}}>All Partitions</a></li>
</ol>
</BlockSlot>
<BlockSlot @name="header">
<h1>
<route.Title @title={{if create "New Partition" (concat "Edit " item.Name)}} />
</h1>
</BlockSlot>
<BlockSlot @name="actions">
</BlockSlot>
<BlockSlot @name="content">
<Consul::Partition::Form
@item={{item}}
@dc={{route.params.dc}}
@nspace={{route.params.nspace}}
@partition={{route.params.partition}}
@onsubmit={{transition-to 'dc.partitions.index'}}
/>
</BlockSlot>
</AppView>
{{/let}}
</BlockSlot>
</DataLoader>
</Route>

View File

@ -0,0 +1,138 @@
<Route
@name={{routeName}}
as |route|>
<DataLoader
@src={{
uri '/${partition}/${nspace}/${dc}/partitions'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
)}}
as |loader|>
<BlockSlot @name="error">
<AppError
@error={{loader.error}}
@login={{route.model.app.login.open}}
/>
</BlockSlot>
<BlockSlot @name="loaded">
{{#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|}}
<AppView>
<BlockSlot @name="notification" as |status type item error|>
<Consul::Partition::Notifications
@type={{type}}
@status={{status}}
@error={{error}}
/>
</BlockSlot>
<BlockSlot @name="header">
<h1>
<route.Title @title="Admin Partitions" />
</h1>
</BlockSlot>
<BlockSlot @name="actions">
<a data-test-create href="{{href-to 'dc.partitions.create'}}" class="type-create">Create</a>
</BlockSlot>
<BlockSlot @name="toolbar">
{{#if (gt items.length 0)}}
<Consul::Partition::SearchBar
@search={{search}}
@onsearch={{action (mut search) value="target.value"}}
@sort={{sort}}
@filter={{filters}}
/>
{{/if}}
</BlockSlot>
<BlockSlot @name="content">
<DataWriter
@sink={{uri '/${partition}/${dc}/${nspace}/partition/'
(hash
partition=route.params.partition
nspace=route.params.nspace
dc=route.params.dc
)
}}
@type="partition"
@ondelete={{refresh-route}}
as |writer|>
<BlockSlot @name="content">
<DataCollection
@type="nspace"
@sort={{sort.value}}
@filters={{filters}}
@search={{search}}
@items={{items}}
as |collection|>
<collection.Collection>
<Consul::Partition::List
@items={{collection.items}}
@ondelete={{writer.delete}}
/>
</collection.Collection>
<collection.Empty>
<EmptyState
@login={{route.model.app.login.open}}
>
<BlockSlot @name="header">
<h2>
{{#if (gt items.length 0)}}
No partitions found
{{else}}
Welcome to Partitions
{{/if}}
</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
{{#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}}
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/commands/FIXME" rel="noopener noreferrer" target="_blank">Documentation on partitions</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/FIXME" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</collection.Empty>
</DataCollection>
</BlockSlot>
</DataWriter>
</BlockSlot>
</AppView>
{{/let}}
</BlockSlot>
</DataLoader>
</Route>

View File

@ -0,0 +1,5 @@
{
"name": "consul-partitions",
"version": "0.1.0",
"private": true
}

View File

@ -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);
}
);

View File

@ -79,6 +79,12 @@ module.exports = {
pattern: '**/README.mdx', pattern: '**/README.mdx',
urlSchema: 'auto', urlSchema: 'auto',
urlPrefix: 'docs/consul', 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), ].concat(user.sources),
labels: { labels: {

View File

@ -38,6 +38,19 @@ export default class BaseAbility extends Ability {
this.permissions.generate(this.resource, ACCESS_WRITE, segment), 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() { get canRead() {
if (typeof this.item !== 'undefined') { if (typeof this.item !== 'undefined') {

View File

@ -7,6 +7,10 @@ export default class NspaceAbility extends BaseAbility {
resource = 'operator'; resource = 'operator';
segmented = false; segmented = false;
get isLinkable() {
return !this.item.DeletedAt;
}
get canManage() { get canManage() {
return this.canCreate; return this.canCreate;
} }

View File

@ -1,4 +1,4 @@
import BaseAbility from './base'; import BaseAbility from 'consul-ui/abilities/base';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
export default class PartitionAbility extends BaseAbility { export default class PartitionAbility extends BaseAbility {
@ -7,6 +7,10 @@ export default class PartitionAbility extends BaseAbility {
resource = 'operator'; resource = 'operator';
segmented = false; segmented = false;
get isLinkable() {
return !this.item.DeletedAt;
}
get canManage() { get canManage() {
return this.canCreate; return this.canCreate;
} }

View File

@ -2,4 +2,8 @@ import BaseAbility from './base';
export default class ServiceAbility extends BaseAbility { export default class ServiceAbility extends BaseAbility {
resource = 'service'; resource = 'service';
get isLinkable() {
return this.item.InstanceCount > 0;
}
} }

View File

@ -0,0 +1,9 @@
import BaseAbility from './base';
export default class UpstreamAbility extends BaseAbility {
resource = 'upstream';
get isLinkable() {
return this.item.InstanceCount > 0;
}
}

View File

@ -1,4 +1,5 @@
import Adapter from './application'; import Adapter from './application';
import { SLUG_KEY } from 'consul-ui/models/partition';
// Blocking query support for partitions is currently disabled // Blocking query support for partitions is currently disabled
export default class PartitionAdapter extends Adapter { 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']); await respond((headers, body) => delete headers['x-consul-index']);
return respond; 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,
}}
`;
}
} }

View File

@ -2,7 +2,7 @@
class="consul-nspace-list" class="consul-nspace-list"
...attributes ...attributes
@items={{@items}} @items={{@items}}
@linkable={{action this.isLinkable}} @linkable="linkable nspace"
as |item|> as |item|>
<BlockSlot @name="header"> <BlockSlot @name="header">
{{#if item.DeletedAt}} {{#if item.DeletedAt}}

View File

@ -1,7 +0,0 @@
import Component from '@glimmer/component';
export default class ConsulNspaceList extends Component {
isLinkable(item) {
return !item.DeletedAt;
}
}

View File

@ -2,7 +2,7 @@
class="consul-service-list" class="consul-service-list"
...attributes ...attributes
@items={{@items}} @items={{@items}}
@linkable={{action this.isLinkable}} @linkable="linkable service"
as |item index| as |item index|
> >
<BlockSlot @name="header"> <BlockSlot @name="header">

View File

@ -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;
}
}

View File

@ -2,7 +2,7 @@
class="consul-upstream-list" class="consul-upstream-list"
...attributes ...attributes
@items={{@items}} @items={{@items}}
@linkable={{action this.isLinkable}} @linkable="linkable upstream"
as |item index|> as |item index|>
<BlockSlot @name="header"> <BlockSlot @name="header">
{{#if (gt item.InstanceCount 0)}} {{#if (gt item.InstanceCount 0)}}

View File

@ -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;
}
}

View File

@ -2,6 +2,7 @@ import Component from '@ember/component';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { set, get } from '@ember/object'; import { set, get } from '@ember/object';
import Slotted from 'block-slots'; import Slotted from 'block-slots';
import { isChangeset } from 'validated-changeset';
export default Component.extend(Slotted, { export default Component.extend(Slotted, {
tagName: '', tagName: '',
@ -41,7 +42,7 @@ export default Component.extend(Slotted, {
setData: function(data) { setData: function(data) {
let changeset = data; let changeset = data;
// convert to a real changeset // convert to a real changeset
if (typeof this.form !== 'undefined') { if (!isChangeset(data) && typeof this.form !== 'undefined') {
changeset = this.form.setData(data).getData(); changeset = this.form.setData(data).getData();
} }
// mark as creating // mark as creating

View File

@ -8,6 +8,7 @@
persist=(action "persist") persist=(action "persist")
delete=(queue (action (mut data)) (action dispatch "REMOVE")) delete=(queue (action (mut data)) (action dispatch "REMOVE"))
inflight=(state-matches state (array "persisting" "removing")) inflight=(state-matches state (array "persisting" "removing"))
disabled=(state-matches state (array "persisting" "removing"))
) as |api|}} ) as |api|}}
{{yield api}} {{yield api}}

View File

@ -48,60 +48,13 @@
</BlockSlot> </BlockSlot>
</PopoverMenu> </PopoverMenu>
</li> </li>
<Consul::Partition::Selector
{{#if (can "choose partitions")}} @dc={{@dc}}
<li @partition={{@partition}}
class="partitions" @nspace={{@nspace}}
data-test-partition-menu @partitions={{this.partitions}}
> @onchange={{action (mut this.partitions) value="data"}}
<PopoverMenu />
aria-label="Admin Partition"
@position="left"
as |components api|>
<BlockSlot @name="trigger">
{{@partition}}
</BlockSlot>
<BlockSlot @name="menu">
{{#let components.MenuItem components.MenuSeparator as |MenuItem MenuSeparator|}}
<DataSource
@src={{uri
'/*/*/${dc}/partitions'
(hash
dc=@dc.Name
)
}}
@onchange={{action (mut this.partitions) value="data"}}
/>
{{#each (reject-by 'DeletedAt' this.partitions) as |item|}}
<MenuItem
class={{if (eq @partition item.Name) 'is-active'}}
@href={{href-to '.' params=(hash
partition=item.Name
nspace=(if (gt @nspace.length 0) @nspace undefined)
)}}
>
<BlockSlot @name="label">
{{item.Name}}
</BlockSlot>
</MenuItem>
{{/each}}
{{#if (and false (can 'manage partitions'))}}
<MenuSeparator />
<MenuItem
data-test-main-nav-partitions
@href={{href-to 'dc.nspaces' @dc.Name}}
>
<BlockSlot @name="label">
Manage Admin Partitions
</BlockSlot>
</MenuItem>
{{/if}}
{{/let}}
</BlockSlot>
</PopoverMenu>
</li>
{{/if}}
{{#if (can "choose nspaces")}} {{#if (can "choose nspaces")}}
<li <li
class="nspaces" class="nspaces"

View File

@ -22,10 +22,7 @@
<li <li
data-test-list-row data-test-list-row
onclick={{action 'click'}} style={{{cell.style}}} onclick={{action 'click'}} style={{{cell.style}}}
class={{if class={{if (not linkable) 'linkable' (if (is linkable item=cell.item) 'linkable')}}
(compute (action (or linkable (noop)) cell.item))
'linkable'
}}
> >
<YieldSlot @name="header"><div class="header">{{yield cell.item cell.index}}</div></YieldSlot> <YieldSlot @name="header"><div class="header">{{yield cell.item cell.index}}</div></YieldSlot>
<YieldSlot @name="details"><div class="detail">{{yield cell.item cell.index}}</div></YieldSlot> <YieldSlot @name="details"><div class="detail">{{yield cell.item cell.index}}</div></YieldSlot>
@ -49,10 +46,7 @@
<li <li
data-test-list-row data-test-list-row
onclick={{action 'click'}} onclick={{action 'click'}}
class={{if class={{if (not linkable) 'linkable' (if (is linkable item=cell.item) 'linkable')}}
(compute (action (or linkable (noop)) item))
'linkable'
}}
> >
<YieldSlot @name="header"><div class="header">{{yield item index}}</div></YieldSlot> <YieldSlot @name="header"><div class="header">{{yield item index}}</div></YieldSlot>
<YieldSlot @name="details"><div class="detail">{{yield item index}}</div></YieldSlot> <YieldSlot @name="details"><div class="detail">{{yield item index}}</div></YieldSlot>

View File

@ -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
<TextInput
@name="single"
@label="Single Line Text Input"
@item={{hash
single=""
}}
@placeholder="Placeholder: Enter some single line text here"
@help="Help me if you can, I'm feeling down"
/>
<hr />
<TextInput
@expanded={{true}}
@name="Description"
@label="Multiline Input"
@item={{hash
Description="Long form text"
}}
/>
```
## 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)
---

View File

@ -0,0 +1,48 @@
<label
class={{concat 'text-input' ' type-text' (if (get @chart.state.context.errors @name) ' has-error')}}
...attributes
>
<span>
{{!- add an optional slot here called <:label>-}}
{{or @label @name}}
</span>
{{!- add an optional slot here called <:input>?-}}
{{#if @expanded}}
<textarea
{{validate @item
validations=@validations
chart=@chart
}}
{{on 'input' (optional @oninput)}}
name={{@name}}
>{{or @value (get @item @name)}}</textarea>
{{else}}
<input
{{validate @item
validations=@validations
chart=@chart
}}
{{on 'input' (optional @oninput)}}
type="text"
value={{or @value (get @item @name)}}
name={{@name}}
placeholder={{or @placeholder}}
/>
{{/if}}
{{#let
(or @validations.help @help)
as |help|}}
{{#if help}}
{{!- add an optional slot here called <:help>?-}}
<em>
{{help}}
</em>
{{/if}}
{{/let}}
<State @state={{@chart.state}} @matches="error">
{{!- add an optional slot here called <:alert/error/success>?-}}
<strong
role="alert"
>{{get (get @chart.state.context.errors @name) 'message'}}</strong>
</State>
</label>

View File

@ -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);
}

View File

@ -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);
},
});

View File

@ -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)

View File

@ -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);
}
}

View File

@ -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.`);
},
});

View File

@ -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.

View File

@ -1,8 +0,0 @@
import Route from 'consul-ui/routing/route';
export default {
name: 'routing',
initialize(application) {
application.register('route:basic', Route);
},
};

View File

@ -1,8 +1,42 @@
import { runInDebug } from '@ember/debug'; 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 { export default {
name: 'container', name: 'container',
initialize(application) { initialize(application) {
inject(application, services);
const container = application.lookup('service:container'); const container = application.lookup('service:container');
// find all the services and add their classes to the container so we can // 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 // look instances up by class afterwards as we then resolve the

View File

@ -199,15 +199,35 @@ export default class FSMWithOptionalLocation {
if (typeof this.router === 'undefined') { if (typeof this.router === 'undefined') {
this.router = this.container.lookup('router:main'); this.router = this.container.lookup('router:main');
} }
const router = this.router._routerMicrolib;
const url = router.generate(routeName, ...params, {
queryParams: {},
});
let withOptional = true; let withOptional = true;
switch (true) { switch (true) {
case routeName === 'settings': case routeName === 'settings':
case routeName.startsWith('docs.'): case routeName.startsWith('docs.'):
withOptional = false; 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); 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 * performs an ember transition/refresh and browser location update using that
*/ */
transitionTo(url) { 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); const transitionURL = this.getURLForTransition(url);
if (this._previousURL === transitionURL) { if (this._previousURL === transitionURL) {
// probably an optional parameter change // probably an optional parameter change

View File

@ -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: {},
},
};

View File

@ -9,6 +9,8 @@ export default class PartitionModel extends Model {
@attr('string') uid; @attr('string') uid;
@attr('string') Name; @attr('string') Name;
@attr('string') Description; @attr('string') Description;
// TODO: Is there some sort of date we can use here
@attr('string') DeletedAt;
@attr('string') Datacenter; @attr('string') Datacenter;
@attr('string') Namespace; // always "" @attr('string') Namespace; // always ""

View File

@ -3,15 +3,22 @@ import { modifier } from 'ember-modifier';
export default modifier(function enabled($element, [bool = true], hash) { export default modifier(function enabled($element, [bool = true], hash) {
if (['input', 'textarea', 'select', 'button'].includes($element.nodeName.toLowerCase())) { if (['input', 'textarea', 'select', 'button'].includes($element.nodeName.toLowerCase())) {
if (bool) { if (bool) {
$element.disabled = bool; $element.setAttribute('disabled', bool);
$element.setAttribute('aria-disabled', bool);
} else { } else {
$element.dataset.disabled = false; $element.dataset.disabled = false;
$element.removeAttribute('disabled');
$element.removeAttribute('aria-disabled');
} }
return; return;
} }
for (const $el of $element.querySelectorAll('input,textarea')) { for (const $el of $element.querySelectorAll('input,textarea,button')) {
if ($el.dataset.disabled !== 'false') { if(bool && $el.dataset.disabled !== 'false') {
$el.disabled = bool; $element.setAttribute('disabled', bool);
$element.setAttribute('aria-disabled', bool);
} else {
$element.removeAttribute('disabled');
$element.removeAttribute('aria-disabled');
} }
} }
}); });

View File

@ -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();
}
}

View File

@ -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 `<TextInput />` 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|}}
<figure>
<figcaption>Valid to begin with</figcaption>
<input
{{validate
validations=validations
onchange=(fn (mut this.validErrors))
}}
type="text"
value={{'this-is-valid-text-add-a-space-to-see-the-validation-error'}}
/>
{{#if this.validErrors.Name}}
<br /><strong>{{this.validErrors.Name.message}}</strong>
{{/if}}
</figure>
<figure>
<figcaption>Invalid to begin with</figcaption>
<input
{{validate
validations=validations
onchange=(fn (mut this.invalidErrors))
}}
type="text"
value={{"not-valid-text remove-the-space"}}
/>
{{#if this.invalidErrors.Name}}
<br /><strong>{{this.invalidErrors.Name.message}}</strong>
{{/if}}
</figure>
{{/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 |

View File

@ -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);
}
}

View File

@ -15,6 +15,22 @@ export default class BaseRoute extends Route {
@service('repository/permission') permissions; @service('repository/permission') permissions;
@service('router') router; @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) { redirect(model, transition) {
// remove any references to index as it is the same as the root routeName // remove any references to index as it is the same as the root routeName
const routeName = this.routeName const routeName = this.routeName

View File

@ -1,11 +1,13 @@
import Oauth2CodeProvider from 'torii/providers/oauth2-code'; import OAuth2CodeProvider from 'torii/providers/oauth2-code';
const NAME = 'oidc-with-url'; export default class OAuth2CodeWithURLProvider extends OAuth2CodeProvider {
const Provider = Oauth2CodeProvider.extend({
name: NAME, name = 'oidc-with-url';
buildUrl: function() {
buildUrl() {
return this.baseUrl; return this.baseUrl;
}, }
open: function(options) {
open(options) {
const name = this.get('name'), const name = this.get('name'),
url = this.buildUrl(), url = this.buildUrl(),
responseParams = ['state', 'code'], responseParams = ['state', 'code'],
@ -20,18 +22,14 @@ const Provider = Oauth2CodeProvider.extend({
provider: name, provider: name,
}; };
}); });
}, }
close: function() {
close() {
const popup = this.get('popup.remote') || {}; const popup = this.get('popup.remote') || {};
if (typeof popup.close === 'function') { if (typeof popup.close === 'function') {
return popup.close(); return popup.close();
} }
}, }
});
export function initialize(application) {
application.register(`torii-provider:${NAME}`, Provider);
} }
export default {
initialize,
};

View File

@ -5,6 +5,7 @@ export default class HttpService extends Service {
@service('settings') settings; @service('settings') settings;
@service('repository/intention') intention; @service('repository/intention') intention;
@service('repository/kv') kv; @service('repository/kv') kv;
@service('repository/partition') partition;
@service('repository/session') session; @service('repository/session') session;
prepare(sink, data, instance) { prepare(sink, data, instance) {

View File

@ -7,6 +7,7 @@ import policy from 'consul-ui/forms/policy';
import role from 'consul-ui/forms/role'; import role from 'consul-ui/forms/role';
import intention from 'consul-ui/forms/intention'; import intention from 'consul-ui/forms/intention';
import nspace from 'consul-ui/forms/nspace'; import nspace from 'consul-ui/forms/nspace';
import partition from 'consul-ui/forms/partition';
const builder = builderFactory(); const builder = builderFactory();
@ -17,6 +18,7 @@ const forms = {
role: role, role: role,
intention: intention, intention: intention,
nspace: nspace, nspace: nspace,
partition: partition,
}; };
export default class FormService extends Service { export default class FormService extends Service {

View File

@ -1,39 +1,13 @@
import ApplicationRoute from '../routes/application'; import I18nService, { formatOptionsSymbol } from 'consul-ui/services/i18n';
import { I18nService, formatOptionsSymbol } from './i18n';
import ucfirst from 'consul-ui/utils/ucfirst'; import ucfirst from 'consul-ui/utils/ucfirst';
import faker from 'faker'; 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 // we currently use HTML in translations, so anything 'word-like' with these
// chars won't get translated // chars won't get translated
const translator = cb => item => (!['<', '>', '='].includes(item) ? cb(item) : item); const translator = cb => item => (!['<', '>', '='].includes(item) ? cb(item) : item);
class DebugI18nService extends I18nService { export default class DebugI18nService extends I18nService {
formatMessage(value, formatOptions) { formatMessage(value, formatOptions) {
const text = super.formatMessage(...arguments); const text = super.formatMessage(...arguments);
let locale = this.env.var('CONSUL_INTL_LOCALE'); let locale = this.env.var('CONSUL_INTL_LOCALE');
@ -82,11 +56,4 @@ class DebugI18nService extends I18nService {
return formatOptions; return formatOptions;
} }
} }
export default {
name: 'debug',
after: 'i18n',
initialize(application) {
application.register('route:application', DebugRoute);
application.register('service:intl', DebugI18nService);
},
};

View File

@ -2,7 +2,7 @@ import IntlService from 'ember-intl/services/intl';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
export const formatOptionsSymbol = Symbol(); export const formatOptionsSymbol = Symbol();
export class I18nService extends IntlService { export default class I18nService extends IntlService {
@service('env') env; @service('env') env;
/** /**
* Additionally injects selected project level environment variables into the * 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);
},
};

View File

@ -6,6 +6,27 @@ import { isChangeset } from 'validated-changeset';
import HTTPError from 'consul-ui/utils/http/error'; import HTTPError from 'consul-ui/utils/http/error';
import { ACCESS_READ } from 'consul-ui/abilities/base'; 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 { export default class RepositoryService extends Service {
@service('store') store; @service('store') store;
@service('env') env; @service('env') env;

View File

@ -1,6 +1,6 @@
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { runInDebug } from '@ember/debug'; 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 { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/nspace';
import dataSource from 'consul-ui/decorators/data-source'; import dataSource from 'consul-ui/decorators/data-source';
@ -75,25 +75,7 @@ export default class NspaceEnabledService extends RepositoryService {
} }
remove(item) { remove(item) {
// Namespace deletion is more of a soft delete. return softDelete(this, item);
// 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;
} }
authorize(dc, nspace) { authorize(dc, nspace) {

View File

@ -1,6 +1,6 @@
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { runInDebug } from '@ember/debug'; 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 { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/partition';
import dataSource from 'consul-ui/decorators/data-source'; import dataSource from 'consul-ui/decorators/data-source';
@ -27,6 +27,7 @@ const findActive = function(items, item) {
const MODEL_NAME = 'partition'; const MODEL_NAME = 'partition';
export default class PartitionRepository extends RepositoryService { export default class PartitionRepository extends RepositoryService {
@service('settings') settings; @service('settings') settings;
@service('form') form;
@service('repository/permission') permissions; @service('repository/permission') permissions;
getModelName() { getModelName() {
@ -49,9 +50,25 @@ export default class PartitionRepository extends RepositoryService {
return super.findAll(...arguments).catch(() => []); return super.findAll(...arguments).catch(() => []);
} }
@dataSource('/:ns/:dc/partition/:id') @dataSource('/:partition/:ns/:dc/partition/:id')
async findBySlug() { async findBySlug(params) {
return super.findBySlug(...arguments); 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 = '') { async getActive(currentName = '') {

View File

@ -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
};
}

View File

@ -4,14 +4,20 @@ import flat from 'flat';
import { createMachine, interpret } from '@xstate/fsm'; import { createMachine, interpret } from '@xstate/fsm';
export default class StateService extends Service { export default class StateService extends Service {
@service('logger')
logger; stateCharts = {};
@service('logger') logger;
// @xstate/fsm // @xstate/fsm
log(chart, state) { log(chart, state) {
// this.logger.execute(`${chart.id} > ${state.value}`); // this.logger.execute(`${chart.id} > ${state.value}`);
} }
stateChart(name) {
return this.stateCharts[name];
}
addGuards(chart, options) { addGuards(chart, options) {
this.guards(chart).forEach(function([path, name]) { this.guards(chart).forEach(function([path, name]) {
// xstate/fsm has no guard lookup // xstate/fsm has no guard lookup

View File

@ -0,0 +1,3 @@
export default ({ properties }) => key => {
return properties(['Name'])(key);
};

View File

@ -13,14 +13,33 @@
html.is-debug body > .brand-loader { html.is-debug body > .brand-loader {
display: none !important; display: none !important;
} }
html.is-debug [class*='partition-'] {
display: block !important;
}
html:not(.with-data-source) .data-source-debug { html:not(.with-data-source) .data-source-debug {
display: none; 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 { html.with-route-announcer .route-title {
@extend %unvisually-hidden; @extend %unvisually-hidden;
} }
.data-source-debug { .data-source-debug {
color: red; @extend %debug-box;
} }
.data-source-debug input:checked + pre code::after { .data-source-debug input:checked + pre code::after {
content: attr(data-json); content: attr(data-json);

View File

@ -1,8 +1,15 @@
'use strict'; 'use strict';
const path = require('path');
const exists = require('fs').existsSync;
const Funnel = require('broccoli-funnel'); const Funnel = require('broccoli-funnel');
const mergeTrees = require('broccoli-merge-trees');
const EmberApp = require('ember-cli/lib/broccoli/ember-app'); const EmberApp = require('ember-cli/lib/broccoli/ember-app');
const utils = require('./config/utils'); const utils = require('./config/utils');
// const BroccoliDebug = require('broccoli-debug');
// const debug = BroccoliDebug.buildDebugCallback(`app:consul-ui`)
module.exports = function(defaults, $ = process.env) { module.exports = function(defaults, $ = process.env) {
// available environments // available environments
// ['production', 'development', 'staging', 'test']; // ['production', 'development', 'staging', 'test'];
@ -10,6 +17,7 @@ module.exports = function(defaults, $ = process.env) {
$ = utils.env($); $ = utils.env($);
const env = EmberApp.env(); const env = EmberApp.env();
const prodlike = ['production', 'staging']; const prodlike = ['production', 'staging'];
const devlike = ['development', 'staging'];
const sourcemaps = !['production'].includes(env) && !$('BABEL_DISABLE_SOURCEMAPS', false); const sourcemaps = !['production'].includes(env) && !$('BABEL_DISABLE_SOURCEMAPS', false);
const trees = {}; const trees = {};
@ -17,6 +25,16 @@ module.exports = function(defaults, $ = process.env) {
const outputPaths = {}; const outputPaths = {};
let excludeFiles = []; let excludeFiles = [];
const apps = [
'consul-acls',
'consul-partitions'
].map(item => {
return {
name: item,
path: path.dirname(require.resolve(`${item}/package.json`))
};
});
const babel = { const babel = {
plugins: [ plugins: [
'@babel/plugin-proposal-object-rest-spread', '@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 // exclude any component/pageobject.js files from anything but test
excludeFiles = excludeFiles.concat([ excludeFiles = excludeFiles.concat([
'components/**/pageobject.js', 'components/**/pageobject.js',
'components/**/test-support.js',
'components/**/*.test-support.js', 'components/**/*.test-support.js',
'components/**/*.test.js', 'components/**/*.test.js',
]) ])
@ -38,6 +57,8 @@ module.exports = function(defaults, $ = process.env) {
// exclude our debug initializer, route and template // exclude our debug initializer, route and template
excludeFiles = excludeFiles.concat([ excludeFiles = excludeFiles.concat([
'instance-initializers/debug.js', 'instance-initializers/debug.js',
'routing/**/*-debug.js',
'services/**/*-debug.js',
'templates/debug.hbs', 'templates/debug.hbs',
'components/debug/**/*.*' 'components/debug/**/*.*'
]) ])
@ -61,11 +82,21 @@ module.exports = function(defaults, $ = process.env) {
['strip-function-call', {'strip': ['Ember.runInDebug']}] ['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( const app = new EmberApp(
Object.assign({}, defaults, { 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 // Use `app.import` to add additional libraries to the generated
// output files. // output files.
// //
@ -163,9 +204,6 @@ module.exports = function(defaults, $ = process.env) {
app.import('vendor/metrics-providers/prometheus.js', { app.import('vendor/metrics-providers/prometheus.js', {
outputFile: 'assets/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', { app.import('vendor/init.js', {
outputFile: 'assets/init.js', outputFile: 'assets/init.js',
}); });

View File

@ -41,23 +41,45 @@ ${environment === 'production' ? `{{jsonEncode .}}` : JSON.stringify(config.oper
"codemirror/mode/yaml/yaml.js": "${rootURL}assets/codemirror/mode/yaml/yaml.js" "codemirror/mode/yaml/yaml.js": "${rootURL}assets/codemirror/mode/yaml/yaml.js"
} }
</script> </script>
<script data-app-name="${appName}" data-${appName}-services src="${rootURL}assets/consul-ui/services.js"></script>
${
environment === 'development' || environment === 'staging'
? `
<script data-app-name="${appName}" data-${appName}-services src="${rootURL}assets/consul-ui/services-debug.js"></script>
` : ``}
${ ${
environment === 'production' environment === 'production'
? ` ? `
{{if .ACLsEnabled}} {{if .ACLsEnabled}}
<script data-${appName}-routing src="${rootURL}assets/acls/routes.js"></script> <script data-app-name="${appName}" data-${appName}-routing src="${rootURL}assets/consul-acls/routes.js"></script>
{{end}}
{{if .PartitionsEnabled}}
<script data-app-name="${appName}" data-${appName}-routing src="${rootURL}assets/consul-partitions/routes.js"></script>
{{end}} {{end}}
` `
: ` : `
<script> <script>
if(document.cookie['CONSUL_ACLS_ENABLED']) { (
const appName = '${appName}'; function(get, obj) {
const appNameJS = appName.split('-').map((item, i) => i ? \`\${item.substr(0, 1).toUpperCase()}\${item.substr(1)}\` : item).join(''); Object.entries(obj).forEach(([key, value]) => {
const $script = document.createElement('script'); if(get(key)) {
$script.setAttribute('src', '${rootURL}assets/acls/routes.js'); const appName = '${appName}';
$script.dataset[\`\${appNameJS}Routes\`] = null; const appNameJS = appName.split('-').map((item, i) => i ? \`\${item.substr(0, 1).toUpperCase()}\${item.substr(1)}\` : item).join('');
document.body.appendChild($script); const $script = document.createElement('script');
$script.setAttribute('data-app-name', '${appName}');
$script.setAttribute('data-${appName}-routing', '');
$script.setAttribute('src', \`${rootURL}assets/\${value}/routes.js\`);
document.body.appendChild($script);
}
});
} }
)(
key => document.cookie.split('; ').find(item => item.startsWith(\`\${key}=\`)),
{
'CONSUL_ACLS_ENABLE': 'consul-acls',
'CONSUL_PARTITIONS_ENABLE': 'consul-partitions'
}
);
</script> </script>
` `
} }

View File

@ -0,0 +1,6 @@
{
"Name": "${location.pathname.get(2)}",
"Description": "${fake.lorem.sentence()}",
"CreateIndex": 12,
"ModifyIndex": 16
}

View File

@ -74,10 +74,13 @@
"babel-plugin-strip-function-call": "^1.0.2", "babel-plugin-strip-function-call": "^1.0.2",
"base64-js": "^1.3.0", "base64-js": "^1.3.0",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"broccoli-debug": "^0.6.5",
"broccoli-funnel": "^3.0.3", "broccoli-funnel": "^3.0.3",
"broccoli-merge-trees": "^4.2.0", "broccoli-merge-trees": "^4.2.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"clipboard": "^2.0.4", "clipboard": "^2.0.4",
"consul-acls": "*",
"consul-partitions": "*",
"css.escape": "^1.5.1", "css.escape": "^1.5.1",
"d3-array": "^2.8.0", "d3-array": "^2.8.0",
"d3-scale": "^3.2.3", "d3-scale": "^3.2.3",

View File

@ -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'],
},
},
},
},
});
})();

View File

@ -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);
}
);

View File

@ -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);
}
);