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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/api-umbrella/admin-ui/app/models/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Admin extends Model.extend(Validations) {
static urlRoot = '/api-umbrella/v1/admins';
static singlePayloadKey = 'admin';
static arrayPayloadKey = 'data';
static duplicateExclude = ['username', 'email'];

@attr()
username;
Expand Down
1 change: 1 addition & 0 deletions src/api-umbrella/admin-ui/app/models/api-scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class ApiScope extends Model.extend(Validations) {
static urlRoot = '/api-umbrella/v1/api_scopes';
static singlePayloadKey = 'api_scope';
static arrayPayloadKey = 'data';
static duplicateExclude = ['pathPrefix'];

@attr()
name;
Expand Down
1 change: 1 addition & 0 deletions src/api-umbrella/admin-ui/app/models/api-user.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class ApiUser extends Model.extend(Validations) {
static urlRoot = '/api-umbrella/v1/users';
static singlePayloadKey = 'user';
static arrayPayloadKey = 'data';
static duplicateExclude = ['apiKey', 'apiKeyHidesAt', 'apiKeyPreview', 'email', 'emailVerified', 'termsAndConditions', 'sendWelcomeEmail'];

@attr()
apiKey;
Expand Down
9 changes: 9 additions & 0 deletions src/api-umbrella/admin-ui/app/models/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ class Api extends Model.extend(Validations) {
if(!this.settings) {
this.set('settings', this.store.createRecord('api/settings'));
}

// This can go away once we move from Ember Data's classic model layer
// to schema records/WarpDrive. We'd need to upgrade to
// ember data 5.x to do that kind of refactor.
this.subSettings.forEach((subSetting) => {
// Accessing the relationship is enough to materialize and fire init.
// eslint-disable-next-line no-unused-expressions
subSetting.settings;
});
}

get exampleIncomingUrlRoot() {
Expand Down
16 changes: 9 additions & 7 deletions src/api-umbrella/admin-ui/app/routes/admin-groups/new.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { inject as service } from '@ember/service';
import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model';
import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route';

import Form from './form';

export default class NewRoute extends Form {
@service store;
export default class NewRoute extends duplicableNewRoute(Form) {
duplicateModelName = 'admin-group';

model() {
clearStoreCache(this.store);
return this.fetchModels(this.store.createRecord('admin-group'));
wrapModel(record) {
return this.fetchModels(record);
}

modelFromResolved(resolved) {
return resolved && resolved.record;
}
}
20 changes: 13 additions & 7 deletions src/api-umbrella/admin-ui/app/routes/admins/new.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { inject as service } from '@ember/service';
import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model';
import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route';

import Form from './form';

export default class NewRoute extends Form {
@service store;
export default class NewRoute extends duplicableNewRoute(Form) {
duplicateModelName = 'admin';

model() {
clearStoreCache(this.store);
return this.fetchModels(this.store.createRecord('admin', { sendInviteEmail: true }));
newRecordAttrs() {
return { sendInviteEmail: true };
}

wrapModel(record) {
return this.fetchModels(record);
}

modelFromResolved(resolved) {
return resolved && resolved.record;
}
}
12 changes: 3 additions & 9 deletions src/api-umbrella/admin-ui/app/routes/api-scopes/new.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { inject as service } from '@ember/service';
import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model';
import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route';

import Form from './form';

export default class NewRoute extends Form {
@service store;

model() {
clearStoreCache(this.store);
return this.store.createRecord('api-scope');
}
export default class NewRoute extends duplicableNewRoute(Form) {
duplicateModelName = 'api-scope';
}
16 changes: 9 additions & 7 deletions src/api-umbrella/admin-ui/app/routes/api-users/new.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { inject as service } from '@ember/service';
import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model';
import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route';

import Form from './form';

export default class NewRoute extends Form {
@service store;
export default class NewRoute extends duplicableNewRoute(Form) {
duplicateModelName = 'api-user';

model() {
clearStoreCache(this.store);
return this.fetchModels(this.store.createRecord('api-user'));
wrapModel(record) {
return this.fetchModels(record);
}

modelFromResolved(resolved) {
return resolved && resolved.record;
}
}
22 changes: 13 additions & 9 deletions src/api-umbrella/admin-ui/app/routes/apis/new.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { inject as service } from '@ember/service';
import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model';
import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route';

import Form from './form';

export default class NewRoute extends Form {
@service store;
export default class NewRoute extends duplicableNewRoute(Form) {
duplicateModelName = 'api';

model() {
clearStoreCache(this.store);
return this.fetchModels(this.store.createRecord('api', {
frontendHost: location.hostname,
}));
newRecordAttrs() {
return { frontendHost: location.hostname };
}

wrapModel(record) {
return this.fetchModels(record);
}

modelFromResolved(resolved) {
return resolved && resolved.record;
}
}
14 changes: 5 additions & 9 deletions src/api-umbrella/admin-ui/app/routes/website-backends/new.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { inject as service } from '@ember/service';
import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model';
import duplicableNewRoute from 'api-umbrella-admin-ui/utils/duplicable-new-route';

import Form from './form';

export default class NewRoute extends Form {
@service store;
export default class NewRoute extends duplicableNewRoute(Form) {
duplicateModelName = 'website-backend';

model() {
clearStoreCache(this.store);
return this.store.createRecord('website-backend', {
serverPort: 80,
});
newRecordAttrs() {
return { serverPort: 80 };
}
}
57 changes: 57 additions & 0 deletions src/api-umbrella/admin-ui/app/services/duplicate-record.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Service, { inject as service } from '@ember/service';
import cloneDeep from 'lodash-es/cloneDeep';

const UNIVERSAL_EXCLUDE = ['id', 'createdAt', 'updatedAt', 'creator', 'updater'];

export default class DuplicateRecordService extends Service {
@service store;

async cloneFromId(modelName, sourceId) {
const source = await this.store.findRecord(modelName, sourceId, { reload: true });
const clone = this._cloneRecord(modelName, source);
clone._duplicatedFromName = source.name || source.email || 'record';
return clone;
}

_cloneRecord(modelName, source) {
const modelClass = this.store.modelFor(modelName);
const exclude = new Set([
...UNIVERSAL_EXCLUDE,
...(modelClass.duplicateExclude || []),
]);

const attrs = {};

modelClass.eachAttribute((name) => {
if(exclude.has(name)) {
return;
}
const value = source.get(name);
attrs[name] = this._isPlainStructure(value) ? cloneDeep(value) : value;
});

modelClass.eachRelationship((name, descriptor) => {
if(exclude.has(name)) {
return;
}
const related = source.get(name);
if(descriptor.kind === 'belongsTo') {
attrs[name] = related ? this._cloneRecord(descriptor.type, related) : null;
} else if(descriptor.kind === 'hasMany') {
attrs[name] = (related || []).map((child) => this._cloneRecord(descriptor.type, child));
}
});

return this.store.createRecord(modelName, attrs);
}

_isPlainStructure(value) {
if(value === null || value === undefined) {
return false;
}
if(Array.isArray(value)) {
return true;
}
return typeof value === 'object' && value.constructor === Object;
}
}
4 changes: 4 additions & 0 deletions src/api-umbrella/admin-ui/app/styles/_forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ fieldset .table {

.form-extra-actions {
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}

.custom-control.custom-control-no-label {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
{{#if this.model.id}}
{{#unless this.isDisabled}}
<div class="form-extra-actions">
<LinkTo @route="admin_groups.new" @query={{hash duplicate_id=this.model.id}} class="duplicate-action"><FaIcon @icon="copy" />Duplicate Admin Group</LinkTo>
<a href="#" class="remove-action" {{action "delete"}}><FaIcon @icon="times" />Delete Admin Group</a>
</div>
{{/unless}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
{{#if this.currentAdmin.permissions.admin_manage}}
{{#if this.model.id}}
<div class="form-extra-actions">
<LinkTo @route="admins.new" @query={{hash duplicate_id=this.model.id}} class="duplicate-action"><FaIcon @icon="copy" />Duplicate Admin</LinkTo>
<a href="#" class="remove-action" {{action "delete"}}><FaIcon @icon="times" />Delete Admin</a>
</div>
{{/if}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
{{#if this.model.id}}
{{#unless this.isDisabled}}
<div class="form-extra-actions">
<LinkTo @route="api_scopes.new" @query={{hash duplicate_id=this.model.id}} class="duplicate-action"><FaIcon @icon="copy" />Duplicate API Scope</LinkTo>
<a href="#" class="remove-action" {{action "delete"}}><FaIcon @icon="times" />Delete API Scope</a>
</div>
{{/unless}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,10 @@
</div>
</div>
</FieldsFor>
{{#if this.model.id}}
<div class="form-extra-actions">
<LinkTo @route="api_users.new" @query={{hash duplicate_id=this.model.id}} class="duplicate-action"><FaIcon @icon="copy" />Duplicate API User</LinkTo>
</div>
{{/if}}
</form>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
</div>
{{#if this.model.id}}
<div class="form-extra-actions">
<LinkTo @route="apis.new" @query={{hash duplicate_id=this.model.id}} class="duplicate-action"><FaIcon @icon="copy" />Duplicate API</LinkTo>
<a href="#" class="remove-action" {{action "delete"}}><FaIcon @icon="times" />Delete API</a>
</div>
{{/if}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
</div>
{{#if this.model.id}}
<div class="form-extra-actions">
<LinkTo @route="website_backends.new" @query={{hash duplicate_id=this.model.id}} class="duplicate-action"><FaIcon @icon="copy" />Duplicate Website Backend</LinkTo>
<a href="#" class="remove-action" {{action "delete"}}><FaIcon @icon="times" />Delete Website Backend</a>
</div>
{{/if}}
Expand Down
64 changes: 64 additions & 0 deletions src/api-umbrella/admin-ui/app/utils/duplicable-new-route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { inject as service } from '@ember/service';
import { success } from '@pnotify/core';
import { clearStoreCache } from 'api-umbrella-admin-ui/utils/uncached-model';

// Returns a route class that adds support for cloning a source record
// when `duplicate_id` is present in the query string. Each new route in
// the admin UI extends `duplicableNewRoute(Form)` instead of `Form`
// directly, so this class sits between Form and the resource's NewRoute.
//
// Subclasses must declare `duplicateModelName` (the Ember Data model
// name). They may override:
// - `newRecordAttrs()` to provide createRecord defaults on the
// non-duplicate path (default: `{}`).
// - `wrapModel(record)` to wrap the resolved record in a hash via
// `this.fetchModels(record)` for resources whose form route loads
// auxiliary data alongside the record (default: identity).
// - `modelFromResolved(resolved)` to extract the record from the hash
// in `afterModel` (default: identity, matching `wrapModel: identity`).
export default function duplicableNewRoute(SuperClass) {
return class DuplicableNewRoute extends SuperClass {
@service store;
@service duplicateRecord;

queryParams = {
duplicate_id: { refreshModel: true },
};

duplicateModelName = null;

newRecordAttrs() {
return {};
}

wrapModel(record) {
return record;
}

modelFromResolved(resolved) {
return resolved;
}

async model(params) {
let record;
if(params.duplicate_id) {
record = await this.duplicateRecord.cloneFromId(this.duplicateModelName, params.duplicate_id);
} else {
clearStoreCache(this.store);
record = this.store.createRecord(this.duplicateModelName, this.newRecordAttrs());
}
return this.wrapModel(record);
}

afterModel(resolved) {
const record = this.modelFromResolved(resolved);
if(record && record._duplicatedFromName) {
success({
title: 'Duplicated',
text: `Duplicated from ${record._duplicatedFromName}`,
});
record._duplicatedFromName = null;
}
}
};
}
1 change: 1 addition & 0 deletions src/api-umbrella/admin-ui/config/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = function() {
'calendar',
'caret-down',
'cog',
'copy',
'lock',
'map-marker-alt',
'pencil-alt',
Expand Down
Loading
Loading