[UI] Ember Data Migration - Config UI Engine (#30238)

* WIP updating config-ui engine to use api service and form class

* updates form-field component to support false values for radio types

* updates api-error-message util to log out error in dev env

* fixes issues in custom messages create and edit workflows

* fixes issues in api service

* updates capabilities handling

* updates to custom messages form

* removes store from custom messages tests

* removes store as dependency from config-ui engine

* removes commented out code in messages route

* updates orderedKeys to displayFields in messages page component

* removes unneccesary method var from message create-and-edit component

* removes comment about model in message details page
This commit is contained in:
Jordan Reimer 2025-04-28 16:09:11 -06:00 committed by GitHub
parent 294c304947
commit e18b0485f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 494 additions and 254 deletions

View File

@ -20,10 +20,11 @@ export default class App extends Application {
'flash-messages',
'namespace',
{ 'app-router': 'router' },
'store',
'pagination',
'version',
'custom-messages',
'api',
'capabilities',
],
},
},

View File

@ -0,0 +1,125 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Form from './form';
import FormField from 'vault/utils/forms/field';
import { isBefore, isAfter } from 'date-fns';
import { encodeString } from 'core/utils/b64';
import type { CreateCustomMessageRequest } from '@hashicorp/vault-client-typescript';
import { Validations } from 'vault/vault/app-types';
type CustomMessageFormData = Partial<CreateCustomMessageRequest>;
export default class CustomMessageForm extends Form {
declare data: CustomMessageFormData;
formFields = [
new FormField('authenticated', undefined, {
label: 'Where should we display this message?',
editType: 'radio',
possibleValues: [
{
label: 'After the user logs in',
subText: 'Display to users after they have successfully logged in to Vault.',
value: true,
id: 'authenticated',
},
{
label: 'On the login page',
subText: 'Display to users on the login page before they have authenticated.',
value: false,
id: 'unauthenticated',
},
],
}),
new FormField('type', 'string', {
label: 'Type',
editType: 'radio',
possibleValues: [
{
label: 'Alert message',
subText:
'A banner that appears on the top of every page to display brief but high-signal messages like an update or system alert.',
value: 'banner',
},
{
label: 'Modal',
subText:
'A pop-up window used to bring immediate attention for important notifications or actions.',
value: 'modal',
},
],
}),
new FormField('title', 'string'),
new FormField('message', 'string', { editType: 'textarea' }),
new FormField('link', 'string', {
editType: 'kv',
keyPlaceholder: 'Display text (e.g. Learn more)',
valuePlaceholder: 'Link URL (e.g. https://www.hashicorp.com/)',
label: 'Link (optional)',
isSingleRow: true,
allowWhiteSpace: true,
}),
new FormField('startTime', 'dateTimeLocal', {
editType: 'dateTimeLocal',
label: 'Message starts',
subText: 'Defaults to 12:00 a.m. the following day (local timezone).',
}),
new FormField('endTime', 'dateTimeLocal', {
editType: 'yield',
label: 'Message expires',
}),
];
validations: Validations = {
title: [{ type: 'presence', message: 'Title is required.' }],
message: [{ type: 'presence', message: 'Message is required.' }],
link: [
{
validator({ link }: CustomMessageFormData) {
if (!link) return true;
const [title] = Object.keys(link);
const [href] = Object.values(link);
return title || href ? !!(title && href) : true;
},
message: 'Link title and url are required.',
},
],
startTime: [
{
validator({ startTime, endTime }: CustomMessageFormData) {
if (!startTime || !endTime) return true;
return isBefore(new Date(startTime), new Date(endTime));
},
message: 'Start time is after end time.',
},
],
endTime: [
{
validator({ startTime, endTime }: CustomMessageFormData) {
if (!startTime || !endTime) return true;
return isAfter(new Date(endTime), new Date(startTime));
},
message: 'End time is before start time.',
},
],
};
toJSON() {
// overriding to do some date serialization
// form sets dates as strings but client expects Date objects
const startTime = this.data.startTime ? new Date(this.data.startTime as unknown as string) : undefined;
const endTime = this.data.endTime ? new Date(this.data.endTime as unknown as string) : undefined;
// encode message to base64
const message = this.data.message ? encodeString(this.data.message) : undefined;
return super.toJSON({ ...this.data, startTime, endTime, message });
}
}

View File

@ -12,8 +12,12 @@ import {
IdentityApi,
SecretsApi,
SystemApi,
HTTPQuery,
HTTPRequestInit,
RequestOpts,
} from '@hashicorp/vault-client-typescript';
import config from '../config/environment';
import { waitForPromise } from '@ember/test-waiters';
import type AuthService from 'vault/services/auth';
import type NamespaceService from 'vault/services/namespace';
@ -80,13 +84,16 @@ export default class ApiService extends Service {
// -- Post Request Middleware --
showWarnings = async (context: ResponseContext) => {
const response = context.response.clone();
const json = await response?.json();
// if the response is empty, don't try to parse it
if (response.headers.get('Content-Length')) {
const json = await response.json();
if (json?.warnings) {
json.warnings.forEach((message: string) => {
this.flashMessages.info(message);
});
}
}
};
deleteControlGroupToken = async (context: ResponseContext) => {
@ -129,6 +136,9 @@ export default class ApiService extends Service {
{ post: this.deleteControlGroupToken },
{ post: this.formatErrorResponse },
],
fetchApi: (...args: [Request]) => {
return waitForPromise(window.fetch(...args));
},
});
auth = new AuthApi(this.configuration);
@ -154,4 +164,12 @@ export default class ApiService extends Service {
return { headers };
}
// convenience method for updating the query params object on the request context
// eg. this.api.sys.uiConfigListCustomMessages(true, ({ context: { query } }) => { query.authenticated = true });
// -> this.api.sys.uiConfigListCustomMessages(true, (context) => this.api.addQueryParams(context, { authenticated: true }));
addQueryParams(requestContext: { init: HTTPRequestInit; context: RequestOpts }, params: HTTPQuery = {}) {
const { context } = requestContext;
context.query = { ...context.query, ...params };
}
}

View File

@ -10,11 +10,17 @@
*/
import { ErrorContext, ApiError } from 'vault/api';
import ENV from 'vault/config/environment';
// accepts an error and returns error.errors joined with a comma, error.message or a fallback message
export default async function (error: unknown, fallbackMessage = 'An error occurred, please try again') {
const messageOrFallback = (message?: string) => message || fallbackMessage;
// log out the error for ease of debugging in dev env
if (ENV.environment === 'development') {
console.error('API Error:', error); // eslint-disable-line no-console
}
if ((error as ErrorContext).response instanceof Response) {
const apiError: ApiError = await (error as ErrorContext).response?.json();

View File

@ -15,7 +15,7 @@ import { datetimeLocalStringFormat } from 'core/utils/date-formatters';
* ```js
* <Messages::MessageExpirationDateForm @message={{this.message}} @attr={{attr}} />
* ```
* @param {array} messages - array message objects
* @param {array} message - message form data
*/
export default class MessageExpirationDateForm extends Component {

View File

@ -6,7 +6,7 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task, timeout } from 'ember-concurrency';
import errorMessage from 'vault/utils/error-message';
import apiErrorMessage from 'vault/utils/api-error-message';
import { service } from '@ember/service';
import { action } from '@ember/object';
import Ember from 'ember';
@ -20,16 +20,18 @@ import timestamp from 'core/utils/timestamp';
* ```js
* <Page::CreateAndEditMessageForm @message={{this.message}} />
* ```
* @param {model} message - message model to pass to form components
* @param message - message to pass to form component
* @param messages - array of all created messages
* @param breadcrumbs - breadcrumbs to pass to the TabPageHeader component
*/
export default class MessagesList extends Component {
@service('app-router') router;
@service store;
@service pagination;
@service flashMessages;
@service customMessages;
@service namespace;
@service api;
@tracked errorBanner = '';
@tracked modelValidations;
@ -38,17 +40,21 @@ export default class MessagesList extends Component {
@tracked showMultipleModalsMessage = false;
@tracked userConfirmation = '';
willDestroy() {
const noTeardown = this.store && !this.store.isDestroying;
const { model } = this;
if (noTeardown && model && model.isDirty && !model.isDestroyed && !model.isDestroying) {
model.rollbackAttributes();
get hasSomeActiveModals() {
const { messages } = this.args;
return messages?.some((message) => message.type === 'modal' && message.active);
}
super.willDestroy();
get hasExpiredModalMessages() {
const modalMessages = this.args.messages?.filter((message) => message.type === 'modal') || [];
return modalMessages.every((message) => {
if (!message.endTime) return false;
return isAfter(timestamp.now(), new Date(message.endTime));
});
}
validate() {
const { isValid, state, invalidFormMessage } = this.args.message.validate();
const { isValid, state, invalidFormMessage } = this.args.message.toJSON();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
return isValid;
@ -60,29 +66,32 @@ export default class MessagesList extends Component {
try {
this.userConfirmation = '';
const { message } = this.args;
const isValid = this.validate();
const modalMessages = this.args.messages?.filter((message) => message.type === 'modal') || [];
const hasExpiredModalMessages = modalMessages.every((message) => {
if (!message.endTime) return false;
return isAfter(timestamp.now(), new Date(message.endTime));
});
if (!hasExpiredModalMessages && this.args.hasSomeActiveModals && this.args.message.type === 'modal') {
if (!this.hasExpiredModalMessages && this.hasSomeActiveModals && message.type === 'modal') {
this.showMultipleModalsMessage = true;
const isConfirmed = yield this.getUserConfirmation.perform();
if (!isConfirmed) return;
}
if (isValid) {
const { isNew } = this.args.message;
const { id, title } = yield this.args.message.save();
this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} ${title} message.`);
this.pagination.clearDataset('config-ui/message');
const { data } = message.toJSON();
let id = data.id;
if (message.isNew) {
const response = yield this.api.sys.createCustomMessage(data);
id = response.data.id;
} else {
yield this.api.sys.uiConfigUpdateCustomMessage(id, data);
}
this.flashMessages.success(`Successfully saved ${data.title} message.`);
this.customMessages.fetchMessages(this.namespace.path);
this.router.transitionTo('vault.cluster.config-ui.messages.message.details', id);
}
} catch (error) {
this.errorBanner = errorMessage(error);
this.errorBanner = yield apiErrorMessage(error);
this.invalidFormAlert = 'There was an error submitting this form.';
}
}

View File

@ -11,7 +11,7 @@
<Toolbar>
<ToolbarActions aria-label="message delete and edit">
{{#if @message.canDeleteCustomMessages}}
{{#if @capabilities.canDelete}}
<ConfirmAction
class="toolbar-button"
@buttonColor="secondary"
@ -23,7 +23,7 @@
/>
<div class="toolbar-separator"></div>
{{/if}}
{{#if @message.canEditCustomMessages}}
{{#if @capabilities.canUpdate}}
<LinkTo class="toolbar-link" @route="messages.message.edit" @model={{@message.id}} data-test-link="edit">
Edit message
<Icon @name="chevron-right" />
@ -32,18 +32,18 @@
</ToolbarActions>
</Toolbar>
{{#each @message.allFields as |attr|}}
{{#if (or (eq attr.name "endTime") (eq attr.name "startTime"))}}
{{#each this.displayFields as |field|}}
{{#if (or (eq field "endTime") (eq field "startTime"))}}
{{! if the attr is an endTime and is falsy, we want to show a 'Never' text value }}
<InfoTableRow
@label={{capitalize (humanize (dasherize attr.name))}}
@label={{capitalize (humanize (dasherize field))}}
@value={{if
(and (eq attr.name "endTime") (not (get @message attr.name)))
(and (eq field "endTime") (not (get @message field)))
"Never"
(date-format (get @message attr.name) "MMM d, yyyy hh:mm aaa" withTimeZone=true)
(date-format (get @message field) "MMM d, yyyy hh:mm aaa" withTimeZone=true)
}}
/>
{{else if (eq attr.name "link")}}
{{else if (eq field "link")}}
{{#if (is-empty-value @message.link)}}
<InfoTableRow @label="Link" @value="None" />
{{else}}
@ -54,7 +54,7 @@
{{/each-in}}
{{/if}}
{{else}}
<InfoTableRow @label={{capitalize (humanize (dasherize attr.name))}} @value={{get @message attr.name}} />
<InfoTableRow @label={{capitalize (humanize (dasherize field))}} @value={{get @message field}} />
{{/if}}
{{/each}}

View File

@ -6,16 +6,17 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import errorMessage from 'vault/utils/error-message';
import apiErrorMessage from 'vault/utils/api-error-message';
/**
* @module Page::MessageDetails
* Page::MessageDetails components are used to display a message
* @example
* ```js
* <Page::MessageDetails @message={{this.message}} />
* <Page::MessageDetails @message={{this.model.message}} @capabilities={{this.model.capabilities}} />
* ```
* @param {model} message - message model
* @param message
* @param capabilities - capabilities for the message
*/
export default class MessageDetails extends Component {
@ -24,18 +25,21 @@ export default class MessageDetails extends Component {
@service customMessages;
@service namespace;
@service pagination;
@service api;
displayFields = ['active', 'type', 'authenticated', 'title', 'message', 'startTime', 'endTime', 'link'];
@action
async deleteMessage() {
try {
await this.args.message.destroyRecord(this.args.message.id);
this.pagination.clearDataset('config-ui/message');
const { message } = this.args;
await this.api.sys.uiConfigDeleteCustomMessage(message.id);
this.router.transitionTo('vault.cluster.config-ui.messages');
this.customMessages.fetchMessages(this.namespace.path);
this.flashMessages.success(`Successfully deleted ${this.args.message.title}.`);
this.flashMessages.success(`Successfully deleted ${message.title}.`);
} catch (e) {
const message = errorMessage(e);
this.flashMessages.danger(message);
const errorMessage = await apiErrorMessage(e);
this.flashMessages.danger(errorMessage);
}
}
}

View File

@ -104,7 +104,7 @@
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
{{#if (or message.canEditCustomMessages message.canDeleteCustomMessages)}}
{{#if (has-capability @capabilities "update" "delete" id=message.id)}}
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@ -112,10 +112,10 @@
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
{{#if message.canEditCustomMessages}}
{{#if (has-capability @capabilities "update" id=message.id)}}
<dd.Interactive @route="messages.message.edit" @model={{message.id}}>Edit</dd.Interactive>
{{/if}}
{{#if message.canDeleteCustomMessages}}
{{#if (has-capability @capabilities "delete" id=message.id)}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.messageToDelete) message)}}

View File

@ -10,7 +10,9 @@ import { task, timeout } from 'ember-concurrency';
import { dateFormat } from 'core/helpers/date-format';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import errorMessage from 'vault/utils/error-message';
import apiErrorMessage from 'vault/utils/api-error-message';
import { isAfter } from 'date-fns';
import timestamp from 'core/utils/timestamp';
/**
* @module Page::MessagesList
@ -28,10 +30,15 @@ export default class MessagesList extends Component {
@service namespace;
@service pagination;
@service('app-router') router;
@service api;
@tracked showMaxMessageModal = false;
@tracked messageToDelete = null;
isStartTimeAfterToday = (message) => {
return isAfter(message.startTime, timestamp.now());
};
get formattedMessages() {
return this.args.messages.map((message) => {
let badgeDisplayText = '';
@ -47,7 +54,7 @@ export default class MessagesList extends Component {
}
badgeColor = 'success';
} else {
if (message.isStartTimeAfterToday) {
if (this.isStartTimeAfterToday(message)) {
badgeDisplayText = `Scheduled: ${dateFormat([message.startTime, 'MMM d, yyyy hh:mm aaa'], {
withTimeZone: true,
})}`;
@ -90,14 +97,13 @@ export default class MessagesList extends Component {
@task
*deleteMessage(message) {
try {
yield message.destroyRecord(message.id);
this.pagination.clearDataset('config-ui/message');
yield this.api.sys.uiConfigDeleteCustomMessage(message.id);
this.router.transitionTo('vault.cluster.config-ui.messages');
this.customMessages.fetchMessages(this.namespace.path);
this.flashMessages.success(`Successfully deleted ${message.title}.`);
} catch (e) {
const message = errorMessage(e);
this.flashMessages.danger(message);
const errorMessage = yield apiErrorMessage(e);
this.flashMessages.danger(errorMessage);
} finally {
this.messageToDelete = null;
}

View File

@ -18,13 +18,14 @@ export default class ConfigUiEngine extends Engine {
dependencies = {
services: [
'auth',
'store',
'pagination',
'flash-messages',
'namespace',
'app-router',
'version',
'custom-messages',
'api',
'capabilities',
],
};
}

View File

@ -5,9 +5,12 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import CustomMessage from 'vault/forms/custom-message';
import { addDays, startOfDay } from 'date-fns';
import timestamp from 'core/utils/timestamp';
export default class MessagesCreateRoute extends Route {
@service store;
@service api;
queryParams = {
authenticated: {
@ -17,9 +20,8 @@ export default class MessagesCreateRoute extends Route {
async getMessages(authenticated) {
try {
return await this.store.query('config-ui/message', {
authenticated,
});
const { keyInfo } = await this.api.sys.uiConfigListCustomMessages(true, undefined, authenticated);
return Object.values(keyInfo);
} catch {
return [];
}
@ -27,17 +29,21 @@ export default class MessagesCreateRoute extends Route {
async model(params) {
const { authenticated } = params;
const message = this.store.createRecord('config-ui/message', {
const message = new CustomMessage(
{
authenticated,
});
type: 'banner',
startTime: addDays(startOfDay(timestamp.now()), 1).toISOString(),
},
{ isNew: true }
);
const messages = await this.getMessages(authenticated);
return {
message,
messages,
authenticated,
hasSomeActiveModals:
messages.length && messages?.some((message) => message.type === 'modal' && message.active),
};
}

View File

@ -5,10 +5,12 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import { paginate } from 'core/utils/paginate-list';
import { PATH_MAP } from 'core/utils/capabilities';
export default class MessagesRoute extends Route {
@service pagination;
@service api;
@service capabilities;
queryParams = {
page: {
@ -28,36 +30,49 @@ export default class MessagesRoute extends Route {
},
};
model(params) {
async model(params) {
const { authenticated, page, pageFilter, status, type } = params;
const filter = pageFilter
? (dataset) => dataset.filter((item) => item?.title.toLowerCase().includes(pageFilter.toLowerCase()))
: null;
let active;
const active = {
active: true,
inactive: false,
}[status];
if (status === 'active') active = true;
if (status === 'inactive') active = false;
const messages = this.pagination
.lazyPaginatedQuery('config-ui/message', {
authenticated,
pageFilter: filter,
try {
const { keyInfo, keys } = await this.api.sys.uiConfigListCustomMessages(
true,
active,
type,
responsePath: 'data.keys',
page: page || 1,
size: 10,
})
.catch((e) => {
if (e.httpStatus === 404) {
return [];
authenticated,
type
);
// ids are in the keys array and can be mapped to the object in keyInfo
// map and set id property on keyInfo object
const data = keys.map((id) => {
const { startTime, endTime, ...message } = keyInfo[id];
// dates returned from list endpoint are strings -- convert to date
return {
id,
...message,
startTime: startTime ? new Date(startTime) : startTime,
endTime: endTime ? new Date(endTime) : endTime,
};
});
const messages = paginate(data, {
page,
pageSize: 2,
filter: pageFilter,
filterKey: 'title',
});
// fetch capabilities for each message path
const paths = messages.map((message) => `${PATH_MAP.customMessages}/${message.id}`);
const capabilities = await this.capabilities.fetch(paths);
return { params, messages, capabilities };
} catch (e) {
if (e.response?.status === 404) {
return { params, messages: [] };
}
throw e;
});
return hash({
params,
messages,
});
}
}
setupController(controller, resolvedModel) {

View File

@ -5,22 +5,36 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { decodeString } from 'core/utils/b64';
import { PATH_MAP } from 'core/utils/capabilities';
export default class MessagesMessageDetailsRoute extends Route {
@service store;
@service api;
@service capabilities;
model() {
async model() {
const { id } = this.paramsFor('messages.message');
return this.store.queryRecord('config-ui/message', id);
const requests = [
this.api.sys.uiConfigReadCustomMessage(id),
this.capabilities.fetchPathCapabilities(`${PATH_MAP.customMessages}/${id}`),
];
const [customMessage, capabilities] = await Promise.all(requests);
customMessage.message = decodeString(customMessage.message);
return {
message: customMessage,
capabilities,
};
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const { message } = resolvedModel;
controller.breadcrumbs = [
{ label: 'Messages', route: 'messages', query: { authenticated: resolvedModel.authenticated } },
{ label: resolvedModel.title },
{ label: 'Messages', route: 'messages', query: { authenticated: message.authenticated } },
{ label: message.title },
];
}
}

View File

@ -5,25 +5,24 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import CustomMessage from 'vault/forms/custom-message';
import { decodeString } from 'core/utils/b64';
export default class MessagesMessageEditRoute extends Route {
@service store;
getMessages(authenticated = true) {
return this.store.query('config-ui/message', { authenticated }).catch(() => []);
}
@service api;
async model() {
const { id } = this.paramsFor('messages.message');
const message = await this.store.queryRecord('config-ui/message', id);
const messages = await this.getMessages(message.authenticated);
return hash({
message,
messages,
hasSomeActiveModals:
messages.length && messages?.some((message) => message.type === 'modal' && message.active),
});
const data = await this.api.sys.uiConfigReadCustomMessage(id);
const { keyInfo, keys } = await this.api.sys.uiConfigListCustomMessages(
true,
undefined,
data.authenticated
);
return {
message: new CustomMessage({ ...data, message: decodeString(data.message) }),
messages: keys.map((id) => ({ ...keyInfo[id], id })),
};
}
setupController(controller, resolvedModel) {

View File

@ -6,6 +6,5 @@
<Messages::Page::CreateAndEdit
@message={{this.model.message}}
@messages={{this.model.messages}}
@hasSomeActiveModals={{this.model.hasSomeActiveModals}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@ -3,4 +3,8 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Messages::Page::Details @message={{this.model}} @breadcrumbs={{this.breadcrumbs}} />
<Messages::Page::Details
@message={{this.model.message}}
@capabilities={{this.model.capabilities}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@ -6,6 +6,5 @@
<Messages::Page::CreateAndEdit
@message={{this.model.message}}
@messages={{this.model.messages}}
@hasSomeActiveModals={{this.model.hasSomeActiveModals}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@ -152,17 +152,18 @@
<RadioButton
class="radio"
name={{@attr.name}}
id={{or val.value val}}
value={{or val.value val}}
@value={{or val.value val}}
id={{or val.id (this.radioValue val)}}
value={{this.radioValue val}}
@value={{this.radioValue val}}
@onChange={{this.setAndBroadcast}}
@groupValue={{get @model this.valuePath}}
@disabled={{and @attr.options.editDisabled (not @model.isNew)}}
data-test-radio={{or val.value val}}
data-test-radio={{or val.id (this.radioValue val)}}
/>
<div class="has-left-margin-xs">
<label
for="{{or val.value val}}"
for={{or val.id (this.radioValue val)}}
value={{this.radioValue val}}
class="has-left-margin-xs is-size-7"
data-test-radio-label={{or val.label val.value val}}
>

View File

@ -12,6 +12,7 @@ import { dasherize } from 'vault/helpers/dasherize';
import { assert } from '@ember/debug';
import { addToArray } from 'vault/helpers/add-to-array';
import { removeFromArray } from 'vault/helpers/remove-from-array';
import { isEmpty } from '@ember/utils';
/**
* @module FormField
@ -68,6 +69,8 @@ export default class FormFieldComponent extends Component {
@tracked showToggleTextInput = false;
@tracked toggleInputEnabled = false;
radioValue = (item) => (isEmpty(item.value) ? item : item.value);
constructor() {
super(...arguments);
const { attr, model } = this.args;

View File

@ -15,6 +15,7 @@ import { CUSTOM_MESSAGES } from 'vault/tests/helpers/config-ui/message-selectors
import timestamp from 'core/utils/timestamp';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import CustomMessage from 'vault/forms/custom-message';
module('Integration | Component | messages/page/create-and-edit', function (hooks) {
setupRenderingTest(hooks);
@ -24,15 +25,24 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
hooks.beforeEach(function () {
const now = new Date('2023-07-02T00:00:00Z'); // stub "now" for testing
sinon.replace(timestamp, 'now', sinon.fake.returns(now));
this.context = { owner: this.engine };
this.store = this.owner.lookup('service:store');
this.message = this.store.createRecord('config-ui/message');
this.message = new CustomMessage(
{
authenticated: true,
type: 'banner',
startTime: addDays(startOfDay(timestamp.now()), 1).toISOString(),
},
{ isNew: true }
);
this.renderComponent = () =>
render(hbs`<Messages::Page::CreateAndEdit @message={{this.message}} @messages={{this.messages}} />`, {
owner: this.engine,
});
});
test('it should display all the create form fields and default radio button values', async function (assert) {
await render(hbs`<Messages::Page::CreateAndEdit @message={{this.message}} />`, {
owner: this.engine,
});
await this.renderComponent();
assert.dom(GENERAL.title).hasText('Create message');
assert.dom(CUSTOM_MESSAGES.radio('authenticated')).exists();
@ -60,9 +70,8 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
test('it should display validation errors for invalid form fields', async function (assert) {
assert.expect(8);
await render(hbs`<Messages::Page::CreateAndEdit @message={{this.message}} />`, {
owner: this.engine,
});
await this.renderComponent();
await fillIn(CUSTOM_MESSAGES.input('startTime'), '2024-01-20T00:00');
await fillIn(CUSTOM_MESSAGES.input('endTime'), '2024-01-01T00:00');
@ -92,9 +101,8 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
assert.ok(true, 'POST request made to create message');
});
await render(hbs`<Messages::Page::CreateAndEdit @message={{this.message}} />`, {
owner: this.engine,
});
await this.renderComponent();
await fillIn(CUSTOM_MESSAGES.input('title'), 'Awesome custom message title');
await fillIn(
CUSTOM_MESSAGES.input('message'),
@ -116,9 +124,9 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
test('it should have form vaildations', async function (assert) {
assert.expect(4);
await render(hbs`<Messages::Page::CreateAndEdit @message={{this.message}} />`, {
owner: this.engine,
});
await this.renderComponent();
await click(CUSTOM_MESSAGES.button('create-message'));
assert
.dom(CUSTOM_MESSAGES.input('title'))
@ -136,21 +144,19 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
test('it should prepopulate form if form is in edit mode', async function (assert) {
assert.expect(13);
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
this.message = new CustomMessage({
id: 'hhhhh-iiii-lllll-dddd',
type: 'modal',
authenticated: false,
title: 'Hello world',
message: 'Blah blah blah. Some super long message.',
start_time: '2023-12-12T08:00:00.000Z',
end_time: '2023-12-21T08:00:00.000Z',
startTime: new Date('2023-12-12T08:00:00.000Z'),
endTime: new Date('2023-12-21T08:00:00.000Z'),
link: { 'Learn more': 'www.learnmore.com' },
});
this.message = this.store.peekRecord('config-ui/message', 'hhhhh-iiii-lllll-dddd');
await render(hbs`<Messages::Page::CreateAndEdit @message={{this.message}} />`, {
owner: this.engine,
});
await this.renderComponent();
assert.dom(GENERAL.title).hasText('Edit message');
assert.dom(CUSTOM_MESSAGES.radio('authenticated')).exists();
@ -174,9 +180,9 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
test('it should show a preview image modal when preview is clicked', async function (assert) {
assert.expect(6);
await render(hbs`<Messages::Page::CreateAndEdit @message={{this.message}} />`, {
owner: this.engine,
});
await this.renderComponent();
await fillIn(CUSTOM_MESSAGES.input('title'), 'Awesome custom message title');
await fillIn(
CUSTOM_MESSAGES.input('message'),
@ -202,9 +208,9 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
test('it should show a preview modal when preview is clicked', async function (assert) {
assert.expect(4);
await render(hbs`<Messages::Page::CreateAndEdit @message={{this.message}} />`, {
owner: this.engine,
});
await this.renderComponent();
await click(CUSTOM_MESSAGES.radio('modal'));
await fillIn(CUSTOM_MESSAGES.input('title'), 'Preview modal title');
await fillIn(CUSTOM_MESSAGES.input('message'), 'Some preview modal message thats super long.');
@ -220,8 +226,8 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
test('it should show multiple modal message', async function (assert) {
assert.expect(2);
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
this.messages = [
{
id: '01234567-89ab-cdef-0123-456789abcdef',
active: true,
type: 'modal',
@ -229,11 +235,10 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
title: 'Message title 1',
message: 'Some long long long message',
link: { here: 'www.example.com' },
startTime: '2021-08-01T00:00:00Z',
startTime: new Date('2021-08-01T00:00:00Z'),
endTime: '',
});
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
},
{
id: '01234567-89ab-vvvv-0123-456789abcdef',
active: true,
type: 'modal',
@ -241,18 +246,13 @@ module('Integration | Component | messages/page/create-and-edit', function (hook
title: 'Message title 2',
message: 'Some long long long message',
link: { here: 'www.example.com' },
startTime: '2021-08-01T00:00:00Z',
endTime: '2090-08-01T00:00:00Z',
});
startTime: new Date('2021-08-01T00:00:00Z'),
endTime: new Date('2090-08-01T00:00:00Z'),
},
];
this.messages = this.store.peekAll('config-ui/message');
await this.renderComponent();
await render(
hbs`<Messages::Page::CreateAndEdit @message={{this.message}} @messages={{this.messages}} @hasSomeActiveModals={{true}} />`,
{
owner: this.engine,
}
);
await fillIn(CUSTOM_MESSAGES.input('title'), 'Awesome custom message title');
await fillIn(
CUSTOM_MESSAGES.input('message'),

View File

@ -29,7 +29,6 @@ module('Integration | Component | messages/page/details', function (hooks) {
hooks.beforeEach(function () {
this.context = { owner: this.engine };
this.store = this.owner.lookup('service:store');
this.server.post('/sys/capabilities-self', () => ({
data: {
@ -37,8 +36,7 @@ module('Integration | Component | messages/page/details', function (hooks) {
},
}));
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
this.message = {
id: '01234567-89ab-cdef-0123-456789abcdef',
active: true,
type: 'banner',
@ -46,19 +44,18 @@ module('Integration | Component | messages/page/details', function (hooks) {
title: 'Message title 1',
message: 'Some long long long message',
link: { here: 'www.example.com' },
start_time: '2021-08-01T00:00:00Z',
end_time: '',
canDeleteCustomMessages: true,
startTime: new Date('2021-08-01T00:00:00Z'),
endTime: undefined,
canEditCustomMessages: true,
});
};
this.capabilities = { canDelete: true, canUpdate: true };
});
test('it should show the message details', async function (assert) {
this.message = await this.store.peekRecord('config-ui/message', '01234567-89ab-cdef-0123-456789abcdef');
await render(hbs`<Messages::Page::Details @message={{this.message}} />`, {
owner: this.engine,
});
await render(
hbs`<Messages::Page::Details @message={{this.message}} @capabilities={{this.capabilities}} />`,
this.context
);
assert.dom('[data-test-page-title]').hasText('Message title 1');
assert
.dom('[data-test-component="info-table-row"]')
@ -88,5 +85,8 @@ module('Integration | Component | messages/page/details', function (hooks) {
.hasText(this.message[field.key], `${field.label} value renders`);
}
});
assert.dom('[data-test-confirm-action="Delete message"]').exists();
assert.dom('[data-test-link="edit"]').exists();
});
});

View File

@ -11,14 +11,18 @@ import { render, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { CUSTOM_MESSAGES } from 'vault/tests/helpers/config-ui/message-selectors';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { addDays, startOfDay } from 'date-fns';
import timestamp from 'core/utils/timestamp';
const META = {
value: {
currentPage: 1,
lastPage: 1,
nextPage: 1,
prevPage: 1,
total: 3,
pageSize: 15,
},
};
module('Integration | Component | messages/page/list', function (hooks) {
@ -28,10 +32,9 @@ module('Integration | Component | messages/page/list', function (hooks) {
hooks.beforeEach(function () {
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
this.store = this.owner.lookup('service:store');
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
this.messages = [
{
id: '0',
active: true,
type: 'banner',
@ -39,11 +42,10 @@ module('Integration | Component | messages/page/list', function (hooks) {
title: 'Message title 1',
message: 'Some long long long message',
link: { title: 'here', href: 'www.example.com' },
start_time: '2021-08-01T00:00:00Z',
end_time: '',
});
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
startTime: new Date('2021-08-01T00:00:00Z'),
endTime: undefined,
},
{
id: '1',
active: false,
type: 'modal',
@ -51,11 +53,10 @@ module('Integration | Component | messages/page/list', function (hooks) {
title: 'Message title 2',
message: 'Some long long long message blah blah blah',
link: { title: 'here', href: 'www.example2.com' },
start_time: '2023-07-01T00:00:00Z',
end_time: '2023-08-01T00:00:00Z',
});
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
startTime: new Date('2023-07-01T00:00:00Z'),
endTime: new Date('2023-08-01T00:00:00Z'),
},
{
id: '2',
active: false,
type: 'banner',
@ -63,7 +64,9 @@ module('Integration | Component | messages/page/list', function (hooks) {
title: 'Message title 3',
message: 'Some long long long message',
link: { title: 'here', href: 'www.example.com' },
});
},
];
Object.defineProperty(this.messages, 'meta', META);
});
test('it should show the messages empty state', async function (assert) {
@ -82,8 +85,6 @@ module('Integration | Component | messages/page/list', function (hooks) {
});
test('it should show the list of custom messages', async function (assert) {
this.messages = this.store.peekAll('config-ui/message', {});
this.messages.meta = META;
await render(hbs`<Messages::Page::List @messages={{this.messages}} />`, {
owner: this.engine,
});
@ -96,8 +97,7 @@ module('Integration | Component | messages/page/list', function (hooks) {
test('it should show max message warning modal', async function (assert) {
for (let i = 0; i < 97; i++) {
this.store.pushPayload('config-ui/message', {
modelName: 'config-ui/message',
this.messages.push({
id: `${i}-a`,
active: true,
type: 'banner',
@ -105,19 +105,12 @@ module('Integration | Component | messages/page/list', function (hooks) {
title: `Message title ${i}`,
message: 'Some long long long message',
link: { title: 'here', href: 'www.example.com' },
start_time: '2021-08-01T00:00:00Z',
startTime: new Date('2021-08-01T00:00:00Z'),
});
}
this.messages.meta.total = this.messages.length;
this.messages.meta.pageSize = 100;
this.messages = this.store.peekAll('config-ui/message', {});
this.messages.meta = {
currentPage: 1,
lastPage: 1,
nextPage: 1,
prevPage: 1,
total: this.messages.length,
pageSize: 100,
};
await render(hbs`<Messages::Page::List @messages={{this.messages}} />`, {
owner: this.engine,
});
@ -134,8 +127,8 @@ module('Integration | Component | messages/page/list', function (hooks) {
});
test('it should show the correct badge colors based on badge status', async function (assert) {
this.messages = this.store.peekAll('config-ui/message', {});
this.messages.meta = META;
this.messages[2].startTime = addDays(startOfDay(timestamp.now()), 1);
await render(hbs`<Messages::Page::List @messages={{this.messages}} />`, {
owner: this.engine,
});

View File

@ -231,6 +231,26 @@ module('Integration | Component | form field', function (hooks) {
assert.strictEqual(model.get('foo'), selectedValue);
assert.ok(spy.calledWith('foo', selectedValue), 'onChange called with correct args');
});
test('it renders: radio buttons false value and id', async function (assert) {
const [model, spy] = await setup.call(
this,
createAttr('foo', null, {
editType: 'radio',
possibleValues: [
{ label: 'True option', value: true, id: 'true-option' },
{ label: 'False option', value: false, id: 'false-option' },
],
})
);
assert.dom('[data-test-radio-label="True option"]').hasTextContaining('True option');
assert.dom('[data-test-radio-label="False option"]').hasTextContaining('False option');
assert.dom('[data-test-radio="true-option"]').hasAttribute('id', 'true-option');
assert.dom('[data-test-radio="false-option"]').hasAttribute('id', 'false-option');
await component.selectRadioInput('false-option');
assert.false(model.get('foo'));
assert.ok(spy.calledWith('foo', false), 'onChange called with correct args');
});
test('it renders: datetimelocal', async function (assert) {
const [model] = await setup.call(
this,

View File

@ -111,13 +111,19 @@ module('Unit | Service | api', function (hooks) {
});
test('it should show warnings', async function (assert) {
const warnings = ['warning1', 'warning2'];
const response = new Response(JSON.stringify({ warnings }));
const warnings = JSON.stringify({ warnings: ['warning1', 'warning2'] });
const response = new Response(warnings, { headers: { 'Content-Length': warnings.length } });
await this.apiService.showWarnings({ response });
assert.true(this.info.firstCall.calledWith(warnings[0]), 'First warning message is shown');
assert.true(this.info.secondCall.calledWith(warnings[1]), 'Second warning message is shown');
assert.true(this.info.firstCall.calledWith('warning1'), 'First warning message is shown');
assert.true(this.info.secondCall.calledWith('warning2'), 'Second warning message is shown');
});
test('it should not attempt to set warnings for empty response', async function (assert) {
const response = new Response();
await this.apiService.showWarnings({ response });
assert.true(this.info.notCalled, 'No warning messages are shown');
});
test('it should delete control group token', async function (assert) {

View File

@ -5,6 +5,8 @@
import { module, test } from 'qunit';
import apiErrorMessage from 'vault/utils/api-error-message';
import ENV from 'vault/config/environment';
import sinon from 'sinon';
module('Unit | Util | api-error-message', function (hooks) {
hooks.beforeEach(function () {
@ -48,4 +50,13 @@ module('Unit | Util | api-error-message', function (hooks) {
const message = await apiErrorMessage('some random error', fallback);
assert.strictEqual(message, fallback);
});
test('it should log out error in development environment', async function (assert) {
const consoleStub = sinon.stub(console, 'error');
sinon.stub(ENV, 'environment').value('development');
const error = new Error('some js type error');
await apiErrorMessage(error);
assert.true(consoleStub.calledWith('API Error:', error));
sinon.restore();
});
});