Skip to content

Commit

Permalink
feat(core): add application-specific custom banners
Browse files Browse the repository at this point in the history
  • Loading branch information
maggieneterval committed Feb 5, 2019
1 parent a93921f commit c6a3528
Show file tree
Hide file tree
Showing 15 changed files with 607 additions and 2 deletions.
@@ -1,7 +1,10 @@
import { cloneDeep } from 'lodash';

import { APPLICATION_DATA_SOURCE_EDITOR } from './dataSources/applicationDataSourceEditor.component';
import { CHAOS_MONKEY_CONFIG_COMPONENT } from 'core/chaosMonkey/chaosMonkeyConfig.component';
import { TRAFFIC_GUARD_CONFIG_COMPONENT } from './trafficGuard/trafficGuardConfig.component';
import { SETTINGS } from 'core/config/settings';
import { ApplicationWriter } from 'core/application/service/ApplicationWriter';

const angular = require('angular');

Expand All @@ -18,7 +21,7 @@ module.exports = angular
TRAFFIC_GUARD_CONFIG_COMPONENT,
require('./links/applicationLinks.component').name,
])
.controller('ApplicationConfigController', function($state, app) {
.controller('ApplicationConfigController', function($state, app, $scope) {
this.application = app;
this.isDataSourceEnabled = key => app.dataSources.some(ds => ds.key === key && ds.disabled === false);
this.feature = SETTINGS.feature;
Expand All @@ -28,4 +31,27 @@ module.exports = angular
this.application.attributes.instancePort =
this.application.attributes.instancePort || SETTINGS.defaultInstancePort || null;
}
this.bannerConfigProps = {
isSaving: false,
saveError: false,
};
this.updateBannerConfigs = bannerConfigs => {
const applicationAttributes = cloneDeep(this.application.attributes);
applicationAttributes.customBanners = bannerConfigs;
$scope.$applyAsync(() => {
this.bannerConfigProps.isSaving = true;
this.bannerConfigProps.saveError = false;
});
ApplicationWriter.updateApplication(applicationAttributes)
.then(() => {
$scope.$applyAsync(() => {
this.bannerConfigProps.isSaving = false;
this.application.attributes = applicationAttributes;
});
})
.catch(() => {
this.bannerConfigProps.isSaving = false;
this.bannerConfigProps.saveError = true;
});
};
});
Expand Up @@ -24,6 +24,15 @@
<page-section key="snapshot" label="Serialize Application" visible="config.feature.snapshots">
<application-snapshot-section application="config.application"></application-snapshot-section>
</page-section>
<page-section key="banner" label="Custom Banners">
<custom-banner-config
banner-configs="config.application.attributes.customBanners"
is-saving="config.bannerConfigProps.isSaving"
save-error="config.bannerConfigProps.saveError"
update-banner-configs="config.updateBannerConfigs"
>
</custom-banner-config>
</page-section>
<page-section key="delete" label="Delete Application">
<delete-application-section application="config.application"></delete-application-section>
</page-section>
Expand Down
@@ -0,0 +1,83 @@
import * as React from 'react';
import { shallow } from 'enzyme';

import { noop } from 'core/utils';

import { CustomBannerConfig, ICustomBannerConfig } from './CustomBannerConfig';

describe('<CustomBannerConfig />', () => {
let bannerConfigs: ICustomBannerConfig[];
let wrapper: any;

beforeEach(() => {
bannerConfigs = getTestBannerConfigs();
wrapper = shallow(
<CustomBannerConfig
bannerConfigs={bannerConfigs}
isSaving={false}
saveError={false}
updateBannerConfigs={noop}
/>,
);
});

describe('view', () => {
it('renders a row for each banner config', () => {
expect(wrapper.find('.custom-banner-config-row').length).toEqual(bannerConfigs.length);
});
it('renders an "add" button', () => {
expect(wrapper.find('.add-new').length).toEqual(1);
});
});

describe('functionality', () => {
it('update banner config', () => {
expect(wrapper.state('bannerConfigsEditing')).toEqual(bannerConfigs);
wrapper
.find('input[type="checkbox"]')
.at(1)
.simulate('change', { target: { checked: true } });
const updatedConfigs = [
{
...bannerConfigs[0],
enabled: false,
},
{
...bannerConfigs[1],
enabled: true,
},
];
expect(wrapper.state('bannerConfigsEditing')).toEqual(updatedConfigs);
});
it('add banner config', () => {
expect(wrapper.state('bannerConfigsEditing').length).toEqual(2);
wrapper.find('.add-new').simulate('click');
expect(wrapper.state('bannerConfigsEditing').length).toEqual(3);
});
it('remove banner config', () => {
expect(wrapper.state('bannerConfigsEditing').length).toEqual(2);
wrapper
.find('.custom-banner-config-remove')
.at(1)
.simulate('click');
expect(wrapper.state('bannerConfigsEditing').length).toEqual(1);
});
});
});

export function getTestBannerConfigs(): ICustomBannerConfig[] {
return [
{
backgroundColor: 'var(--color-alert)',
enabled: true,
text: 'Warning: currently in maintenance mode',
textColor: 'var(--color-text-on-dark)',
},
{
backgroundColor: 'var(--color-alert)',
enabled: false,
text: 'Warning: currently in production freeze',
textColor: 'var(--color-text-on-dark)',
},
];
}
@@ -0,0 +1,230 @@
import * as React from 'react';
import { isEqual } from 'lodash';
import Select, { Option } from 'react-select';

import { ConfigSectionFooter } from 'core/application/config/footer/ConfigSectionFooter';
import {
bannerBackgroundColorOptions,
bannerTextColorOptions,
} from 'core/application/config/customBanner/customBannerColors';

import { noop } from 'core/utils';

import './customBannerConfig.less';

export interface ICustomBannerConfig {
backgroundColor: string;
enabled: boolean;
text: string;
textColor: string;
}

export interface ICustomBannerConfigProps {
bannerConfigs: ICustomBannerConfig[];
isSaving: boolean;
saveError: boolean;
updateBannerConfigs: (bannerConfigs: ICustomBannerConfig[]) => void;
}

export interface ICustomBannerConfigState {
bannerConfigsEditing: ICustomBannerConfig[];
}

export class CustomBannerConfig extends React.Component<ICustomBannerConfigProps, ICustomBannerConfigState> {
public static defaultProps: Partial<ICustomBannerConfigProps> = {
bannerConfigs: [],
isSaving: false,
saveError: false,
updateBannerConfigs: noop,
};

constructor(props: ICustomBannerConfigProps) {
super(props);
this.state = {
bannerConfigsEditing: props.bannerConfigs,
};
}

private onEnabledChange = (idx: number, isChecked: boolean) => {
this.setState({
bannerConfigsEditing: this.state.bannerConfigsEditing.map((config, i) => {
if (i === idx) {
return {
...config,
enabled: isChecked,
};
}
// Only one config can be enabled
return {
...config,
enabled: isChecked ? false : config.enabled,
};
}),
});
};

private onTextChange = (idx: number, text: string) => {
this.setState({
bannerConfigsEditing: this.state.bannerConfigsEditing.map((config, i) => {
if (i === idx) {
return {
...config,
text,
};
}
return config;
}),
});
};

private onTextColorChange = (idx: number, option: Option<string>) => {
this.setState({
bannerConfigsEditing: this.state.bannerConfigsEditing.map((config, i) => {
if (i === idx) {
return {
...config,
textColor: option.value,
};
}
return config;
}),
});
};

private onBackgroundColorChange = (idx: number, option: Option<string>) => {
this.setState({
bannerConfigsEditing: this.state.bannerConfigsEditing.map((config, i) => {
if (i === idx) {
return {
...config,
backgroundColor: option.value,
};
}
return config;
}),
});
};

private addBanner = (): void => {
this.setState({
bannerConfigsEditing: this.state.bannerConfigsEditing.concat([
{
backgroundColor: 'var(--color-alert)',
enabled: false,
text: 'Your custom banner text',
textColor: 'var(--color-text-on-dark)',
} as ICustomBannerConfig,
]),
});
};

private removeBanner = (idx: number): void => {
this.setState({
bannerConfigsEditing: this.state.bannerConfigsEditing.filter((_config, i) => i !== idx),
});
};

private isDirty = (): boolean => {
return !isEqual(this.props.bannerConfigs, this.state.bannerConfigsEditing);
};

private onRevertClicked = (): void => {
this.setState({
bannerConfigsEditing: this.props.bannerConfigs,
});
};

private onSaveClicked = (): void => {
this.props.updateBannerConfigs(this.state.bannerConfigsEditing);
};

private colorOptionRenderer = (option: Option<string>): JSX.Element => {
return <div className="custom-banner-config-color-option" style={{ backgroundColor: option.value }} />;
};

public render() {
return (
<div className="custom-banner-config-container">
<div className="custom-banner-config-description">
Custom Banners allow you to specify application-specific headers that will appear above the main Spinnaker
navigation bar.
</div>
<div className="col-md-10 col-md-offset-1">
<table className="table table-condensed">
<thead>
<tr>
<th className="text-center">Enabled</th>
<th>Text</th>
<th className="custom-banner-config-color-option-column">Text Color</th>
<th className="custom-banner-config-color-option-column">Background</th>
<th />
</tr>
</thead>
<tbody>
{this.state.bannerConfigsEditing.map((banner, idx) => (
<tr key={idx} className="custom-banner-config-row">
<td className="text-center">
<input
checked={banner.enabled}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => this.onEnabledChange(idx, e.target.checked)}
type="checkbox"
/>
</td>
<td>
<textarea
className="form-control input-sm custom-banner-config-textarea"
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => this.onTextChange(idx, e.target.value)}
style={{
backgroundColor: banner.backgroundColor,
color: banner.textColor,
}}
value={banner.text}
/>
</td>
<td>
<Select
clearable={false}
options={bannerTextColorOptions}
onChange={(option: Option<string>) => this.onTextColorChange(idx, option)}
optionRenderer={this.colorOptionRenderer}
value={banner.textColor}
valueRenderer={this.colorOptionRenderer}
/>
</td>
<td>
<Select
clearable={false}
options={bannerBackgroundColorOptions}
onChange={(option: Option<string>) => this.onBackgroundColorChange(idx, option)}
optionRenderer={this.colorOptionRenderer}
value={banner.backgroundColor}
valueRenderer={this.colorOptionRenderer}
/>
</td>
<td>
<button className="link custom-banner-config-remove" onClick={() => this.removeBanner(idx)}>
<span className="glyphicon glyphicon-trash" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="col-md-10 col-md-offset-1">
<button className="btn btn-block add-new" onClick={this.addBanner}>
<span className="glyphicon glyphicon-plus-sign" /> Add banner
</button>
</div>
<ConfigSectionFooter
isDirty={this.isDirty()}
isValid={true}
isSaving={this.props.isSaving}
saveError={false}
onRevertClicked={this.onRevertClicked}
onSaveClicked={this.onSaveClicked}
/>
</div>
);
}
}

0 comments on commit c6a3528

Please sign in to comment.