Unverified Commit 1eae8e8d authored by Marcos Iglesias's avatar Marcos Iglesias Committed by GitHub

feat: Announcements in Homepage (#591)

* feat: AnnouncementsList component (#540)

* Adds fake endpoint return for development

* Basic Announcements list

* Basic unstyled Announcements list

* Restoring proper announcements endpoint code

* Linting issues
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* feat: Announcements container and saga, api and reducer modifications (#541)

* Basic container

* Adding status code to announcement response

* Updating Announcements reducer, sagas and api to support loading and error states

* Linting details

* Completing the global state fixture

* Basic tests for connection
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* Basic card with loading shimmer (#546)
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* feat: Card Styling for announcements (#550)

* Shimmering card styles

* Basic card typography styling

* Add links and link styles to cards

* Adjusting card copy per Knowl specs
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* feat: Wiring announcements block on Homepage (#551)

* Adds announcemetns to homepage, integrates basic card

* Spacing and details

* Adjusting loading state

* Adds card and see more links logging

* Updates layout size; focus detail

* Variables on List component styles

* Cleaning fake response
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* feat: Adds config for announcements (#562)

* Moves config tests, adds config for announcements and test
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* Wiring announcements feature to the config option
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* Updating configuration docs
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* Extracting navLinks logic into the config utils
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* Adds LPL typography to announcements; some layout fine-tuning (#581)
Signed-off-by: 's avatarMarcos Iglesias Valle <golodhros@gmail.com>
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>

* Fixing merge conflicts
Signed-off-by: 's avatarMarcos Iglesias <miglesiasvalle@lyft.com>
parent 651059bf
...@@ -18,14 +18,15 @@ $loading-curve: cubic-bezier(0.45, 0, 0.15, 1); ...@@ -18,14 +18,15 @@ $loading-curve: cubic-bezier(0.45, 0, 0.15, 1);
.is-shimmer-animated { .is-shimmer-animated {
animation: $loading-duration shimmer $loading-curve infinite; animation: $loading-duration shimmer $loading-curve infinite;
background-image: linear-gradient( background-image:
to right, linear-gradient(
$gray10 0%, to right,
$gray10 33%, $gray10 0%,
$gray5 50%, $gray10 33%,
$gray10 67%, $gray5 50%,
$gray10 100% $gray10 67%,
); $gray10 100%
);
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 300% 100%; background-size: 300% 100%;
} }
...@@ -5,6 +5,8 @@ ...@@ -5,6 +5,8 @@
$resource-header-height: 84px; $resource-header-height: 84px;
$aside-separation-space: 40px; $aside-separation-space: 40px;
$screen-lg-max: 1490px;
$screen-lg-container: 1440px;
.resource-detail-layout { .resource-detail-layout {
height: calc(100vh - #{$nav-bar-height} - #{$footer-height}); height: calc(100vh - #{$nav-bar-height} - #{$footer-height});
...@@ -198,6 +200,12 @@ $aside-separation-space: 40px; ...@@ -198,6 +200,12 @@ $aside-separation-space: 40px;
min-width: $body-min-width; min-width: $body-min-width;
} }
@media (min-width: $screen-lg-max) {
#main > .container {
width: $screen-lg-container;
}
}
#main > .container { #main > .container {
margin: 96px auto 48px; margin: 96px auto 48px;
} }
......
...@@ -3,6 +3,150 @@ ...@@ -3,6 +3,150 @@
@import 'variables'; @import 'variables';
// New Typography styles based on LPL
// Use these styles going forward
// Placeholder selectors
// Ref: http://thesassway.com/intermediate/understanding-placeholder-selectors
%text-headline-w1 {
font-family: $text-heading-font-family;
font-size: $w1-headline-font-size;
line-height: $w1-headline-line-height;
font-weight: $title-font-weight;
}
%text-headline-w2 {
font-family: $text-heading-font-family;
font-size: $w2-headline-font-size;
line-height: $w2-headline-line-height;
font-weight: $title-font-weight;
}
%text-headline-w3 {
font-family: $text-heading-font-family;
font-size: $w3-headline-font-size;
line-height: $w3-headline-line-height;
font-weight: $title-font-weight;
}
%text-title-w1 {
font-family: $text-body-font-family;
font-size: $w1-font-size;
line-height: $w1-line-height;
font-weight: $title-font-weight;
}
%text-title-w2 {
font-family: $text-body-font-family;
font-size: $w2-font-size;
line-height: $w2-line-height;
font-weight: $title-font-weight;
}
%text-title-w3 {
font-family: $text-body-font-family;
font-size: $w3-font-size;
line-height: $w3-line-height;
font-weight: $title-font-weight;
}
%text-subtitle-w1 {
font-family: $text-body-font-family;
font-size: $w1-font-size;
line-height: $w1-line-height;
font-weight: $subtitle-font-weight;
}
%text-subtitle-w2 {
font-family: $text-body-font-family;
font-size: $w2-font-size;
line-height: $w2-line-height;
font-weight: $subtitle-font-weight;
}
%text-subtitle-w3 {
font-family: $text-body-font-family;
font-size: $w3-font-size;
line-height: $w3-line-height;
font-weight: $subtitle-font-weight;
}
%text-body-w1 {
font-family: $text-body-font-family;
font-size: $w1-font-size;
line-height: $w1-line-height;
font-weight: $body-font-weight;
}
%text-body-w2 {
font-family: $text-body-font-family;
font-size: $w2-font-size;
line-height: $w2-line-height;
font-weight: $body-font-weight;
}
%text-body-w3 {
font-family: $text-body-font-family;
font-size: $w3-font-size;
line-height: $w3-line-height;
font-weight: $body-font-weight;
}
// Typography classes
// Headlines
.text-headline-w1 {
@extend %text-headline-w1;
}
.text-headline-w2 {
@extend %text-headline-w2;
}
.text-headline-w3 {
@extend %text-headline-w3;
}
// Titles
.text-title-w1 {
@extend %text-title-w1;
}
.text-title-w2 {
@extend %text-title-w2;
}
.text-title-w3 {
@extend %text-title-w3;
}
// Subtitles
.text-subtitle-w1 {
@extend %text-subtitle-w1;
}
.text-subtitle-w2 {
@extend %text-subtitle-w2;
}
.text-subtitle-w3 {
@extend %text-subtitle-w3;
}
// Body
.text-body-w1 {
@extend %text-body-w1;
}
.text-body-w2 {
@extend %text-body-w2;
}
.text-body-w3 {
@extend %text-body-w3;
}
// Typography Helpers
.text-center { .text-center {
text-align: center; text-align: center;
} }
...@@ -15,6 +159,40 @@ ...@@ -15,6 +159,40 @@
text-align: right; text-align: right;
} }
.truncated {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Text for Screen Readers only
// Reference: Bootstrap 4 codebase
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
// From https://gist.github.com/igorescobar/d74a76629bab47d601d71c3a6e010ff2
@mixin truncate($font-size, $line-height, $lines-to-show) {
display: block; // Fallback for non-webkit
display: -webkit-box;
font-size: $font-size;
line-height: $line-height;
-webkit-line-clamp: $lines-to-show;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
// Old typography styles
// DEPRECATED - Don't use!
h1, h1,
h2, h2,
h3, h3,
...@@ -184,23 +362,3 @@ body { ...@@ -184,23 +362,3 @@ body {
.text-primary { .text-primary {
color: $text-primary; color: $text-primary;
} }
.truncated {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Text for Screen Readers only
// Reference: Bootstrap 4 codebase
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
...@@ -27,7 +27,6 @@ $stroke-underline: $gray40 !default; ...@@ -27,7 +27,6 @@ $stroke-underline: $gray40 !default;
// Typography // Typography
$text-primary: $gray100 !default; $text-primary: $gray100 !default;
$text-secondary: $gray60 !default; $text-secondary: $gray60 !default;
$text-secondary: $gray60 !default;
$text-tertiary: $gray40 !default; $text-tertiary: $gray40 !default;
$text-placeholder: $gray40 !default; $text-placeholder: $gray40 !default;
$text-inverse: $white !default; $text-inverse: $white !default;
...@@ -130,3 +129,39 @@ $spacer-1: $spacer-size; ...@@ -130,3 +129,39 @@ $spacer-1: $spacer-size;
$spacer-2: $spacer-size * 2; $spacer-2: $spacer-size * 2;
$spacer-3: $spacer-size * 3; $spacer-3: $spacer-size * 3;
$spacer-4: $spacer-size * 4; $spacer-4: $spacer-size * 4;
// Elevations (from LPL)
$elevation-level1: 0 0 1px 0 rgba(0, 0, 0, 0.12),
0 1px 1px 1px rgba(0, 0, 0, 0.08);
$elevation-level2: 0 0 1px 0 rgba(0, 0, 0, 0.12),
0 2px 3px 0 rgba(0, 0, 0, 0.16);
$elevation-level3: 0 0 1px 0 rgba(0, 0, 0, 0.12),
0 2px 4px 0 rgba(0, 0, 0, 0.16);
$elevation-level4: 0 0 1px 0 rgba(0, 0, 0, 0.12),
0 3px 6px 0 rgba(0, 0, 0, 0.16);
// New Typography variables based on LPL
$text-heading-font-family: $font-family-header;
$text-body-font-family: $font-family-body;
$w1-font-size: 20px;
$w1-line-height: 24px;
$w2-font-size: 16px;
$w2-line-height: 20px;
$w3-font-size: 14px;
$w3-line-height: 18px;
$w1-headline-font-size: 36px;
$w1-headline-line-height: 40px;
$w2-headline-font-size: 26px;
$w2-headline-line-height: 28px;
$w3-headline-font-size: 22px;
$w3-headline-line-height: 24px;
$title-font-weight: $font-weight-body-bold;
$subtitle-font-weight: $font-weight-body-semi-bold;
$body-font-weight: $font-weight-body-regular;
...@@ -13,7 +13,7 @@ import { ...@@ -13,7 +13,7 @@ import {
AnnouncementPageProps, AnnouncementPageProps,
mapDispatchToProps, mapDispatchToProps,
mapStateToProps, mapStateToProps,
} from '..'; } from '.';
describe('AnnouncementPage', () => { describe('AnnouncementPage', () => {
let props: AnnouncementPageProps; let props: AnnouncementPageProps;
......
...@@ -14,9 +14,11 @@ import './styles.scss'; ...@@ -14,9 +14,11 @@ import './styles.scss';
import { GlobalState } from 'ducks/rootReducer'; import { GlobalState } from 'ducks/rootReducer';
import { GetAnnouncementsRequest } from 'ducks/announcements/types'; import { GetAnnouncementsRequest } from 'ducks/announcements/types';
import { getAnnouncements } from 'ducks/announcements/reducer'; import { getAnnouncements } from 'ducks/announcements';
import { AnnouncementPost } from 'interfaces'; import { AnnouncementPost } from 'interfaces';
const ANNOUNCEMENTS_HEADER_TEXT = 'Announcements';
export interface StateFromProps { export interface StateFromProps {
posts: AnnouncementPost[]; posts: AnnouncementPost[];
} }
...@@ -29,7 +31,9 @@ export type AnnouncementPageProps = StateFromProps & DispatchFromProps; ...@@ -29,7 +31,9 @@ export type AnnouncementPageProps = StateFromProps & DispatchFromProps;
export class AnnouncementPage extends React.Component<AnnouncementPageProps> { export class AnnouncementPage extends React.Component<AnnouncementPageProps> {
componentDidMount() { componentDidMount() {
this.props.announcementsGet(); const { announcementsGet } = this.props;
announcementsGet();
} }
createPost(post: AnnouncementPost, postIndex: number) { createPost(post: AnnouncementPost, postIndex: number) {
...@@ -47,7 +51,9 @@ export class AnnouncementPage extends React.Component<AnnouncementPageProps> { ...@@ -47,7 +51,9 @@ export class AnnouncementPage extends React.Component<AnnouncementPageProps> {
} }
createPosts() { createPosts() {
return this.props.posts.map((post, index) => { const { posts } = this.props;
return posts.map((post, index) => {
return this.createPost(post, index); return this.createPost(post, index);
}); });
} }
...@@ -57,9 +63,9 @@ export class AnnouncementPage extends React.Component<AnnouncementPageProps> { ...@@ -57,9 +63,9 @@ export class AnnouncementPage extends React.Component<AnnouncementPageProps> {
<DocumentTitle title="Announcements - Amundsen"> <DocumentTitle title="Announcements - Amundsen">
<main className="container announcement-container"> <main className="container announcement-container">
<div className="row"> <div className="row">
<div className="col-xs-12"> <div className="col-xs-12 col-md-10 col-md-offset-1">
<h1 id="announcement-header" className="h3"> <h1 id="announcement-header" className="h3">
Announcements {ANNOUNCEMENTS_HEADER_TEXT}
</h1> </h1>
<hr /> <hr />
<div id="announcement-content" className="announcement-content"> <div id="announcement-content" className="announcement-content">
......
...@@ -7,7 +7,7 @@ import * as DocumentTitle from 'react-document-title'; ...@@ -7,7 +7,7 @@ import * as DocumentTitle from 'react-document-title';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import TagsListContainer from 'components/common/Tags'; import TagsListContainer from 'components/common/Tags';
import { BrowsePage } from '..'; import { BrowsePage } from '.';
describe('BrowsePage', () => { describe('BrowsePage', () => {
const setup = () => { const setup = () => {
......
...@@ -17,7 +17,7 @@ export class BrowsePage extends React.Component { ...@@ -17,7 +17,7 @@ export class BrowsePage extends React.Component {
<DocumentTitle title={BROWSE_PAGE_DOCUMENT_TITLE}> <DocumentTitle title={BROWSE_PAGE_DOCUMENT_TITLE}>
<main className="container"> <main className="container">
<div className="row"> <div className="row">
<div className="col-xs-12"> <div className="col-xs-12 col-md-10 col-md-offset-1">
<TagsListContainer shortTagsList={false} /> <TagsListContainer shortTagsList={false} />
</div> </div>
</div> </div>
......
...@@ -9,13 +9,18 @@ import { RouteComponentProps } from 'react-router'; ...@@ -9,13 +9,18 @@ import { RouteComponentProps } from 'react-router';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
import './styles.scss'; import './styles.scss';
import { resetSearchState } from 'ducks/search/reducer';
import { UpdateSearchStateReset } from 'ducks/search/types';
import MyBookmarks from 'components/common/Bookmark/MyBookmarks'; import MyBookmarks from 'components/common/Bookmark/MyBookmarks';
import Breadcrumb from 'components/common/Breadcrumb'; import Breadcrumb from 'components/common/Breadcrumb';
import PopularTables from 'components/common/PopularTables'; import PopularTables from 'components/common/PopularTables';
import { resetSearchState } from 'ducks/search/reducer';
import { UpdateSearchStateReset } from 'ducks/search/types';
import SearchBar from 'components/common/SearchBar'; import SearchBar from 'components/common/SearchBar';
import TagsListContainer from 'components/common/Tags'; import TagsListContainer from 'components/common/Tags';
import Announcements from 'components/common/Announcements';
import { announcementsEnabled } from 'config/config-utils';
import { SEARCH_BREADCRUMB_TEXT, HOMEPAGE_TITLE } from './constants'; import { SEARCH_BREADCRUMB_TEXT, HOMEPAGE_TITLE } from './constants';
export interface DispatchFromProps { export interface DispatchFromProps {
...@@ -36,7 +41,11 @@ export class HomePage extends React.Component<HomePageProps> { ...@@ -36,7 +41,11 @@ export class HomePage extends React.Component<HomePageProps> {
return ( return (
<main className="container home-page"> <main className="container home-page">
<div className="row"> <div className="row">
<div className="col-xs-12 col-md-offset-1 col-md-10"> <div
className={`col-xs-12 ${
announcementsEnabled() ? 'col-md-8' : 'col-md-offset-1 col-md-10'
}`}
>
<h1 className="sr-only">{HOMEPAGE_TITLE}</h1> <h1 className="sr-only">{HOMEPAGE_TITLE}</h1>
<SearchBar /> <SearchBar />
<div className="filter-breadcrumb pull-right"> <div className="filter-breadcrumb pull-right">
...@@ -56,6 +65,11 @@ export class HomePage extends React.Component<HomePageProps> { ...@@ -56,6 +65,11 @@ export class HomePage extends React.Component<HomePageProps> {
<PopularTables /> <PopularTables />
</div> </div>
</div> </div>
{announcementsEnabled() && (
<div className="col-xs-12 col-md-offset-1 col-md-3">
<Announcements />
</div>
)}
</div> </div>
</main> </main>
); );
......
...@@ -15,7 +15,11 @@ import { Dropdown, MenuItem } from 'react-bootstrap'; ...@@ -15,7 +15,11 @@ import { Dropdown, MenuItem } from 'react-bootstrap';
import { LoggedInUser } from 'interfaces'; import { LoggedInUser } from 'interfaces';
import { feedbackEnabled, indexUsersEnabled } from 'config/config-utils'; import {
feedbackEnabled,
indexUsersEnabled,
getNavLinks,
} from 'config/config-utils';
import Feedback from 'components/Feedback'; import Feedback from 'components/Feedback';
import SearchBar from 'components/common/SearchBar'; import SearchBar from 'components/common/SearchBar';
...@@ -101,7 +105,7 @@ export class NavBar extends React.Component<NavBarProps> { ...@@ -101,7 +105,7 @@ export class NavBar extends React.Component<NavBarProps> {
</div> </div>
{this.renderSearchBar()} {this.renderSearchBar()}
<div id="nav-bar-right" className="ml-auto nav-bar-right"> <div id="nav-bar-right" className="ml-auto nav-bar-right">
{this.generateNavLinks(AppConfig.navLinks)} {this.generateNavLinks(getNavLinks())}
{feedbackEnabled() && <Feedback />} {feedbackEnabled() && <Feedback />}
{loggedInUser && indexUsersEnabled() && ( {loggedInUser && indexUsersEnabled() && (
<Dropdown id="user-dropdown" pullRight> <Dropdown id="user-dropdown" pullRight>
......
...@@ -83,9 +83,10 @@ $avatar-container-size: 40px; ...@@ -83,9 +83,10 @@ $avatar-container-size: 40px;
.nav-search-bar { .nav-search-bar {
flex-grow: 1; flex-grow: 1;
margin: auto $spacer-2 auto auto; margin: auto $spacer-2 auto auto;
.search-bar { .search-bar {
max-width: 560px; max-width: 560px;
margin: auto 0px auto auto; margin: auto 0 auto auto;
} }
} }
......
...@@ -10,7 +10,6 @@ import { Issue } from 'interfaces'; ...@@ -10,7 +10,6 @@ import { Issue } from 'interfaces';
import { getIssues } from 'ducks/issue/reducer'; import { getIssues } from 'ducks/issue/reducer';
import { logClick } from 'ducks/utilMethods'; import { logClick } from 'ducks/utilMethods';
import { GetIssuesRequest } from 'ducks/issue/types'; import { GetIssuesRequest } from 'ducks/issue/types';
import LoadingSpinner from 'components/common/LoadingSpinner';
import ReportTableIssue from 'components/TableDetail/ReportTableIssue'; import ReportTableIssue from 'components/TableDetail/ReportTableIssue';
import { NO_DATA_ISSUES_TEXT } from './constants'; import { NO_DATA_ISSUES_TEXT } from './constants';
import './styles.scss'; import './styles.scss';
......
import * as React from 'react';
import { Link, BrowserRouter } from 'react-router-dom';
import SanitizedHTML from 'react-sanitized-html';
import { mount } from 'enzyme';
import Card from '../../Card';
import AnnouncementsList, { AnnouncementsListProps } from '.';
const TWO_FAKE_ANNOUNCEMENTS = [
{
date: '12/31/1999',
title: 'Y2K',
html_content: '<div>The end of the world</div>',
},
{
date: '01/01/2000',
title: 'False Alarm',
html_content: '<div>Just kidding</div>',
},
];
const FOUR_FAKE_ANNOUNCEMENTS = [
{
date: '12/31/1999',
title: 'Y2K',
html_content: '<div>The end of the world</div>',
},
{
date: '01/01/2000',
title: 'False Alarm',
html_content: '<div>Just kidding</div>',
},
{
date: '12/31/2009',
title: 'Old Test',
html_content: '<div>Old test</div>',
},
{
date: '01/01/2020',
title: 'New Test',
html_content: '<div>New test</div>',
},
];
const EMPTY_ANNOUNCEMENTS = [];
const setup = (propOverrides?: Partial<AnnouncementsListProps>) => {
const props = {
announcements: [],
...propOverrides,
};
const wrapper = mount<typeof AnnouncementsList>(
<BrowserRouter>
<AnnouncementsList {...props} />
</BrowserRouter>
);
return { props, wrapper };
};
describe('AnnouncementsList', () => {
describe('render', () => {
it('renders without issues', () => {
expect(() => {
setup();
}).not.toThrow();
});
it('renders a title', () => {
const { wrapper } = setup();
const expected = 1;
const actual = wrapper.find('.announcements-list-title').length;
expect(actual).toEqual(expected);
});
describe('See more link', () => {
it('should render a "See more" link', () => {
const { wrapper } = setup({ announcements: TWO_FAKE_ANNOUNCEMENTS });
const expected = 1;
const actual = wrapper.find('a.announcements-list-more-link').length;
expect(actual).toEqual(expected);
});
it('renders a react router Link', () => {
const { wrapper } = setup({ announcements: TWO_FAKE_ANNOUNCEMENTS });
const expected = TWO_FAKE_ANNOUNCEMENTS.length + 1;
const actual = wrapper.find(Link).length;
expect(actual).toEqual(expected);
});
it('takes users to the announcements page', () => {
const { wrapper } = setup({ announcements: TWO_FAKE_ANNOUNCEMENTS });
const expected = '/announcements';
const actual = wrapper
.find('a.announcements-list-more-link')
.getDOMNode()
.attributes.getNamedItem('href').value;
expect(actual).toEqual(expected);
});
});
describe('announcements list', () => {
it('should render a list container', () => {
const { wrapper } = setup();
const expected = 1;
const actual = wrapper.find('.announcements-list').length;
expect(actual).toEqual(expected);
});
describe('when loading', () => {
it('should render three cards', () => {
const { wrapper } = setup({
announcements: EMPTY_ANNOUNCEMENTS,
isLoading: true,
});
const expected = 3;
const actual = wrapper.find(Card).length;
expect(actual).toEqual(expected);
});
it('should render three cards in loading state', () => {
const { wrapper } = setup({
announcements: EMPTY_ANNOUNCEMENTS,
isLoading: true,
});
const expected = 3;
const actual = wrapper.find('.card-shimmer-loader').length;
expect(actual).toEqual(expected);
});
it('should not render the see more link', () => {
const { wrapper } = setup({
announcements: EMPTY_ANNOUNCEMENTS,
isLoading: true,
});
const expected = 0;
const actual = wrapper.find('a.announcements-list-more-link').length;
expect(actual).toEqual(expected);
});
});
describe('when non-empty list of announcements', () => {
it('should render announcements', () => {
const { wrapper } = setup({ announcements: TWO_FAKE_ANNOUNCEMENTS });
const expected = 2;
const actual = wrapper.find('.announcement').length;
expect(actual).toEqual(expected);
});
it('should render announcement cards', () => {
const { wrapper } = setup({ announcements: TWO_FAKE_ANNOUNCEMENTS });
const expected = 2;
const actual = wrapper.find(Card).length;
expect(actual).toEqual(expected);
});
describe('when number of announcements is more than three', () => {
it('should render three announcements', () => {
const { wrapper } = setup({
announcements: FOUR_FAKE_ANNOUNCEMENTS,
});
const expected = 3;
const actual = wrapper.find(Card).length;
expect(actual).toEqual(expected);
});
});
});
describe('when empty list of announcements', () => {
it('should render the empty message', () => {
const { wrapper } = setup({ announcements: EMPTY_ANNOUNCEMENTS });
const expected = 1;
const actual = wrapper.find('.empty-announcement').length;
expect(actual).toEqual(expected);
});
it('should not render the see more link', () => {
const { wrapper } = setup({ announcements: EMPTY_ANNOUNCEMENTS });
const expected = 0;
const actual = wrapper.find('a.announcements-list-more-link').length;
expect(actual).toEqual(expected);
});
});
describe('when error on fetch', () => {
it('should render the error message', () => {
const { wrapper } = setup({
announcements: EMPTY_ANNOUNCEMENTS,
hasError: true,
});
const expected = 1;
const actual = wrapper.find('.error-announcement').length;
expect(actual).toEqual(expected);
});
it('should not render the see more link', () => {
const { wrapper } = setup({
announcements: EMPTY_ANNOUNCEMENTS,
hasError: true,
});
const expected = 0;
const actual = wrapper.find('a.announcements-list-more-link').length;
expect(actual).toEqual(expected);
});
});
});
});
});
import * as React from 'react';
import { Link } from 'react-router-dom';
import SanitizedHTML from 'react-sanitized-html';
import { logClick } from 'ducks/utilMethods';
import { AnnouncementPost } from 'interfaces';
import Card from '../../Card';
import {
MORE_LINK_TEXT,
NO_ANNOUNCEMENTS_TEXT,
ANNOUNCEMENTS_ERROR_TEXT,
HEADER_TEXT,
} from '../constants';
import './styles.scss';
const ANNOUNCEMENT_LIST_THRESHOLD = 3;
const ANNOUNCEMENTS_PAGE_PATH = '/announcements';
export interface AnnouncementsListProps {
announcements: AnnouncementPost[];
hasError?: boolean;
isLoading?: boolean;
}
const getLatestsAnnouncements = (announcements: AnnouncementPost[]) =>
announcements.length > ANNOUNCEMENT_LIST_THRESHOLD
? announcements.splice(announcements.length - ANNOUNCEMENT_LIST_THRESHOLD)
: announcements;
const times = (numItems: number) => new Array(numItems).fill(0);
const AnnouncementItem: React.FC<AnnouncementPost> = ({
date,
title,
html_content,
}: AnnouncementPost) => {
return (
<li className="announcement">
<Card
title={title}
subtitle={date}
href={ANNOUNCEMENTS_PAGE_PATH}
onClick={logClick}
copy={
<SanitizedHTML className="announcement-content" html={html_content} />
}
/>
</li>
);
};
const EmptyAnnouncementItem: React.FC = () => (
<li className="empty-announcement">{NO_ANNOUNCEMENTS_TEXT}</li>
);
const AnnouncementErrorItem: React.FC = () => (
<li className="error-announcement">{ANNOUNCEMENTS_ERROR_TEXT}</li>
);
const AnnouncementsList: React.FC<AnnouncementsListProps> = ({
announcements,
hasError,
isLoading,
}: AnnouncementsListProps) => {
const isEmpty = announcements.length === 0;
let listContent = null;
if (isEmpty) {
listContent = <EmptyAnnouncementItem />;
}
if (announcements.length > 0) {
listContent = getLatestsAnnouncements(
announcements
).map(({ date, title, html_content }) => (
<AnnouncementItem
key={`key:${date}`}
date={date}
title={title}
html_content={html_content}
/>
));
}
if (hasError) {
listContent = <AnnouncementErrorItem />;
}
if (isLoading) {
listContent = times(3).map((_, index) => (
<li className="announcement" key={`key:${index}`}>
<Card isLoading />
</li>
));
}
return (
<article className="announcements-list-container">
<h2 className="announcements-list-title">{HEADER_TEXT}</h2>
<ul className="announcements-list">{listContent}</ul>
{!isEmpty && (
<Link
to={ANNOUNCEMENTS_PAGE_PATH}
className="announcements-list-more-link"
onClick={logClick}
>
{MORE_LINK_TEXT}
</Link>
)}
</article>
);
};
export default AnnouncementsList;
@import 'variables';
@import 'typography-default';
$more-link-size: 18px;
$more-link-line-height: 21px;
$message-size: 14px;
$message-line-height: 16px;
$message-border-size: 1px;
.announcements-list-title {
@extend %text-title-w1;
}
.announcements-list {
list-style: none;
margin: $spacer-1 0 0 0;
padding: 0;
.announcement {
margin-bottom: -1px;
}
.announcement-content p {
margin: 0;
}
}
.announcements-list-more-link {
font-size: $more-link-size;
line-height: $more-link-line-height;
margin-top: 12px;
display: block;
}
.empty-announcement,
.error-announcement {
font-size: $message-size;
line-height: $message-line-height;
color: $text-primary;
text-align: center;
border-top: $message-border-size solid $gray20;
padding-top: $spacer-3;
}
export const HEADER_TEXT = 'Announcements';
export const MORE_LINK_TEXT = 'See more';
export const NO_ANNOUNCEMENTS_TEXT = 'No announcements to show';
export const ANNOUNCEMENTS_ERROR_TEXT = 'Could not fetch announcements';
import * as React from 'react';
import { shallow } from 'enzyme';
import globalState from 'fixtures/globalState';
import { ResourceType } from 'interfaces';
import { mapDispatchToProps, mapStateToProps } from '.';
describe('AnnouncementsListContainer', () => {
describe('mapDispatchToProps', () => {
let dispatch;
let props;
beforeAll(() => {
dispatch = jest.fn(() => Promise.resolve());
props = mapDispatchToProps(dispatch);
});
describe('announcementsGet', () => {
it('sets announcementsGet on the props', () => {
const expected = 'function';
const actual = typeof props.announcementsGet;
expect(actual).toEqual(expected);
});
it('should request the announcements', () => {
const expected = { type: 'amundsen/announcements/GET_REQUEST' };
props.announcementsGet();
expect(dispatch.mock.calls[0][0]).toEqual(expected);
});
});
});
describe('mapStateToProps', () => {
it('sets isLoading on the props', () => {
const expected = globalState.announcements.isLoading;
const actual = mapStateToProps(globalState).isLoading;
expect(actual).toEqual(expected);
});
it('sets statusCode on the props', () => {
const expected = globalState.announcements.statusCode;
const actual = mapStateToProps(globalState).statusCode;
expect(actual).toEqual(expected);
});
it('sets posts on the props', () => {
const expected = globalState.announcements.posts;
const actual = mapStateToProps(globalState).announcements;
expect(actual).toEqual(expected);
});
});
});
import * as React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { GlobalState } from 'ducks/rootReducer';
import { GetAnnouncementsRequest } from 'ducks/announcements/types';
import { getAnnouncements } from 'ducks/announcements';
import { AnnouncementPost } from 'interfaces';
import AnnouncementsList from './AnnouncementsList';
export interface StateFromProps {
isLoading: boolean;
statusCode: number;
announcements: AnnouncementPost[];
}
export interface DispatchFromProps {
announcementsGet: () => GetAnnouncementsRequest;
}
export type AnnouncementContainerProps = StateFromProps & DispatchFromProps;
const OK_STATUS_CODE = 200;
const AnnouncementsListContainer: React.FC<AnnouncementContainerProps> = ({
announcements,
announcementsGet,
isLoading,
statusCode,
}: AnnouncementContainerProps) => {
React.useEffect(() => {
announcementsGet();
}, []);
return (
<AnnouncementsList
hasError={statusCode !== OK_STATUS_CODE}
isLoading={isLoading}
announcements={announcements}
/>
);
};
export const mapStateToProps = (state: GlobalState) => {
return {
announcements: state.announcements.posts,
isLoading: state.announcements.isLoading,
statusCode: state.announcements.statusCode,
};
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ announcementsGet: getAnnouncements }, dispatch);
};
export default connect<StateFromProps, DispatchFromProps>(
mapStateToProps,
mapDispatchToProps
)(AnnouncementsListContainer);
// Copyright Contributors to the Amundsen project. // Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
@import 'variables'; @import 'variables';
.clickable-badge { .clickable-badge {
&:hover { &:hover {
cursor: pointer; cursor: pointer;
} }
.label { .label {
padding: 0 0; padding: 0 0;
} }
...@@ -29,37 +31,45 @@ ...@@ -29,37 +31,45 @@
outline-offset: -2px; outline-offset: -2px;
} }
} }
.badge-overlay-negative, .badge-overlay-negative,
.badge-overlay-neutral, .badge-overlay-neutral,
.badge-overlay-positive, .badge-overlay-positive,
.badge-overlay-primary { .badge-overlay-primary {
&:hover, &:hover,
&:focus { &:focus {
background-color: rgba( background-color:
$color: $badge-overlay, rgba(
$alpha: $badge-opacity-light $color: $badge-overlay,
); $alpha: $badge-opacity-light
);
} }
&:active { &:active {
background-color: rgba( background-color:
$color: $badge-overlay, rgba(
$alpha: $badge-pressed-light $color: $badge-overlay,
); $alpha: $badge-pressed-light
);
} }
} }
.badge-overlay-warning { .badge-overlay-warning {
&:hover, &:hover,
&:focus { &:focus {
background-color: rgba( background-color:
$color: $badge-overlay, rgba(
$alpha: $badge-opacity-dark $color: $badge-overlay,
); $alpha: $badge-opacity-dark
);
} }
&:active { &:active {
background-color: rgba( background-color:
$color: $badge-overlay, rgba(
$alpha: $badge-pressed-dark $color: $badge-overlay,
); $alpha: $badge-pressed-dark
);
} }
} }
} }
import * as React from 'react';
import { Link, BrowserRouter } from 'react-router-dom';
import { mount } from 'enzyme';
import Card, { CardProps } from '.';
const setup = (propOverrides?: Partial<CardProps>) => {
const props = {
...propOverrides,
};
const wrapper = mount<typeof Card>(
<BrowserRouter>
<Card {...props} />
</BrowserRouter>
);
return { props, wrapper };
};
describe('Card', () => {
describe('render', () => {
it('renders without issues', () => {
expect(() => {
setup();
}).not.toThrow();
});
it('renders the main container', () => {
const { wrapper } = setup();
const expected = 1;
const actual = wrapper.find('.card').length;
expect(actual).toEqual(expected);
});
describe('header', () => {
it('renders a header section', () => {
const { wrapper } = setup();
const expected = 1;
const actual = wrapper.find('.card-header').length;
expect(actual).toEqual(expected);
});
describe('subtitle', () => {
it('renders a title if passed', () => {
const { wrapper } = setup({ title: 'test title' });
const expected = 1;
const actual = wrapper.find('.card-title').length;
expect(actual).toEqual(expected);
});
it('does not render a title if missing', () => {
const { wrapper } = setup();
const expected = 0;
const actual = wrapper.find('.card-title').length;
expect(actual).toEqual(expected);
});
});
describe('subtitle', () => {
it('renders a subtitle if passed', () => {
const { wrapper } = setup({ subtitle: 'test subtitle' });
const expected = 1;
const actual = wrapper.find('.card-subtitle').length;
expect(actual).toEqual(expected);
});
it('does not render a subtitle if missing', () => {
const { wrapper } = setup();
const expected = 0;
const actual = wrapper.find('.card-subtitle').length;
expect(actual).toEqual(expected);
});
});
});
describe('body', () => {
it('renders a body section', () => {
const { wrapper } = setup();
const expected = 1;
const actual = wrapper.find('.card-body').length;
expect(actual).toEqual(expected);
});
describe('copy', () => {
it('renders a copy if passed', () => {
const { wrapper } = setup({ copy: 'test copy' });
const expected = 1;
const actual = wrapper.find('.card-copy').length;
expect(actual).toEqual(expected);
});
it('does not render a copy if missing', () => {
const { wrapper } = setup();
const expected = 0;
const actual = wrapper.find('.card-copy').length;
expect(actual).toEqual(expected);
});
});
});
describe('when is loading', () => {
it('holds a loading state', () => {
const { wrapper } = setup({ isLoading: true });
const expected = 1;
const actual = wrapper.find('.card.is-loading').length;
expect(actual).toEqual(expected);
});
it('renders a shimmer loader', () => {
const { wrapper } = setup({ isLoading: true });
const expected = 1;
const actual = wrapper.find('.card-shimmer-loader').length;
expect(actual).toEqual(expected);
});
it('renders five rows of line loaders', () => {
const { wrapper } = setup({ isLoading: true });
const expected = 5;
const actual = wrapper.find('.card-shimmer-row').length;
expect(actual).toEqual(expected);
});
});
describe('when an href is passed', () => {
it('should render a link', () => {
const testPath = 'fakePath';
const { wrapper } = setup({ href: testPath });
const expected = 1;
const actual = wrapper.find('a.card').length;
expect(actual).toEqual(expected);
});
it('renders a react router Link', () => {
const testPath = 'fakePath';
const { wrapper } = setup({ href: testPath });
const expected = 1;
const actual = wrapper.find(Link).length;
expect(actual).toEqual(expected);
});
it('sets the link to the passed href', () => {
const testPath = 'fakePath';
const { wrapper } = setup({ href: testPath });
const expected = '/' + testPath;
const actual = wrapper
.find('a.card')
.getDOMNode()
.attributes.getNamedItem('href').value;
expect(actual).toEqual(expected);
});
});
});
describe('lifetime', () => {
describe('when clicking on an interactive card', () => {
it('should call the onClick handler', () => {
const clickSpy = jest.fn();
const { wrapper } = setup({
onClick: clickSpy,
href: 'testPath',
});
const expected = 1;
wrapper.find(Link).simulate('click');
const actual = clickSpy.mock.calls.length;
expect(actual).toEqual(expected);
});
});
});
});
import * as React from 'react';
import { Link } from 'react-router-dom';
import './styles.scss';
export interface CardProps {
title?: string;
subtitle?: string;
copy?: string | JSX.Element;
isLoading?: boolean;
href?: string;
onClick?: (e: React.SyntheticEvent) => void;
}
const CardShimmerLoader: React.FC = () => (
<div className="card-shimmer-loader">
<div className="card-shimmer-row shimmer-row-line--1 is-shimmer-animated" />
<div className="card-shimmer-row shimmer-row-line--2 is-shimmer-animated" />
<div className="card-shimmer-row shimmer-row-line--3 is-shimmer-animated" />
<div className="card-shimmer-loader-body">
<div className="card-shimmer-row shimmer-row-line--4 is-shimmer-animated" />
<div className="card-shimmer-row shimmer-row-line--5 is-shimmer-animated" />
</div>
</div>
);
const Card: React.FC<CardProps> = ({
href,
title,
subtitle,
copy,
onClick = null,
isLoading = false,
}: CardProps) => {
let card;
let cardContent = (
<>
<header className="card-header">
{title && <h2 className="card-title">{title}</h2>}
{subtitle && <h3 className="card-subtitle">{subtitle}</h3>}
</header>
<div className="card-body">
{copy && <div className="card-copy">{copy}</div>}
</div>
</>
);
if (isLoading) {
cardContent = <CardShimmerLoader />;
}
if (href) {
card = (
<Link
className={`card is-link ${isLoading ? 'is-loading' : ''}`}
to={href}
onClick={onClick}
>
{cardContent}
</Link>
);
} else {
card = (
<article className={`card ${isLoading ? 'is-loading' : ''}`}>
{cardContent}
</article>
);
}
return <>{card}</>;
};
export default Card;
@import 'variables';
@import 'typography-default';
$shimmer-loader-items: 1, 2, 3, 4, 5;
$shimmer-loader-row-height: 16px;
$shimmer-loader-row-min-width: 90;
$shimmer-loader-row-max-width: 230;
$card-height: 180px;
$card-header-height: 60px;
$card-border-size: 1px;
$card-focus-border-size: 2px;
$card-title-max-lines: 2;
$card-copy-max-lines: 3;
.card {
display: block;
padding: $spacer-3;
border-top: $card-border-size solid $gray20;
border-bottom: $card-border-size solid $gray20;
height: $card-height;
&.is-link {
&:focus {
text-decoration: none;
border: $card-focus-border-size solid $blue80;
border-radius: $spacer-1/2;
outline-offset: 0;
}
&:hover,
&:active {
text-decoration: none;
box-shadow: $elevation-level2;
border: 0;
}
}
}
.card-header {
height: $card-header-height;
}
.card-title {
@extend %text-title-w2;
color: $text-primary;
@include truncate($w2-font-size, $w2-line-height, $card-title-max-lines);
}
.card-subtitle {
@extend %text-body-w3;
color: $text-secondary;
}
.card-copy {
@extend %text-body-w3;
color: $text-primary;
margin: 0;
@include truncate($w3-font-size, $w3-line-height, $card-copy-max-lines);
}
.card-body {
padding-top: $spacer-2;
}
// Shimmer Loader
.card-shimmer-loader {
width: 100%;
}
.card-shimmer-row {
height: $shimmer-loader-row-height;
width: $shimmer-loader-row-min-width + px;
margin-bottom: $spacer-1;
&:last-child {
margin-bottom: 0;
}
}
@each $line in $shimmer-loader-items {
.shimmer-row-line--#{$line} {
width: (
random($shimmer-loader-row-max-width - $shimmer-loader-row-min-width) +
$shimmer-loader-row-min-width
) +
px;
}
}
.card-shimmer-loader-body {
margin-top: $spacer-4;
}
...@@ -21,9 +21,10 @@ $shimmer-loader-tag-min-width: 50; ...@@ -21,9 +21,10 @@ $shimmer-loader-tag-min-width: 50;
@each $item in $shimmer-loader-items { @each $item in $shimmer-loader-items {
.shimmer-tag-loader-item--#{$item} { .shimmer-tag-loader-item--#{$item} {
width: ( width:
(
random($shimmer-loader-tag-max-width - $shimmer-loader-tag-min-width) + random($shimmer-loader-tag-max-width - $shimmer-loader-tag-min-width) +
$shimmer-loader-tag-min-width $shimmer-loader-tag-min-width
) + ) +
px; px;
} }
......
...@@ -37,6 +37,9 @@ const configDefault: AppConfig = { ...@@ -37,6 +37,9 @@ const configDefault: AppConfig = {
feedbackEnabled: false, feedbackEnabled: false,
notificationsEnabled: false, notificationsEnabled: false,
}, },
announcements: {
enabled: true,
},
navLinks: [ navLinks: [
{ {
label: 'Announcements', label: 'Announcements',
......
...@@ -18,6 +18,7 @@ export interface AppConfig { ...@@ -18,6 +18,7 @@ export interface AppConfig {
issueTracking: IssueTrackingConfig; issueTracking: IssueTrackingConfig;
logoPath: string | null; logoPath: string | null;
mailClientFeatures: MailClientFeaturesConfig; mailClientFeatures: MailClientFeaturesConfig;
announcements: AnnoucementsFeaturesConfig;
navLinks: Array<LinkConfig>; navLinks: Array<LinkConfig>;
resourceConfig: ResourceConfig; resourceConfig: ResourceConfig;
tableLineage: TableLineageConfig; tableLineage: TableLineageConfig;
...@@ -188,6 +189,16 @@ interface MailClientFeaturesConfig { ...@@ -188,6 +189,16 @@ interface MailClientFeaturesConfig {
feedbackEnabled: boolean; feedbackEnabled: boolean;
notificationsEnabled: boolean; notificationsEnabled: boolean;
} }
/**
* AnnoucementsFeaturesConfig - Enable/disable UI features related to the announcements
*
* enabled - Enables the announcements feature
*/
interface AnnoucementsFeaturesConfig {
enabled: boolean;
}
/** /**
* TableProfileConfig - Customize the "Table Profile" section of the "Table Details" page. * TableProfileConfig - Customize the "Table Profile" section of the "Table Details" page.
* *
......
...@@ -2,12 +2,13 @@ import AppConfig from 'config/config'; ...@@ -2,12 +2,13 @@ import AppConfig from 'config/config';
import { BadgeStyleConfig, BadgeStyle } from 'config/config-types'; import { BadgeStyleConfig, BadgeStyle } from 'config/config-types';
import { TableMetadata } from 'interfaces/TableMetadata'; import { TableMetadata } from 'interfaces/TableMetadata';
import { FilterConfig } from './config-types'; import { FilterConfig, LinkConfig } from './config-types';
import { ResourceType } from '../interfaces'; import { ResourceType } from '../interfaces';
export const DEFAULT_DATABASE_ICON_CLASS = 'icon-database icon-color'; export const DEFAULT_DATABASE_ICON_CLASS = 'icon-database icon-color';
export const DEFAULT_DASHBOARD_ICON_CLASS = 'icon-dashboard icon-color'; export const DEFAULT_DASHBOARD_ICON_CLASS = 'icon-dashboard icon-color';
const ANNOUNCEMENTS_LINK_LABEL = 'Announcements';
/** /**
* Returns the display name for a given source id for a given resource type. * Returns the display name for a given source id for a given resource type.
...@@ -96,6 +97,13 @@ export function feedbackEnabled(): boolean { ...@@ -96,6 +97,13 @@ export function feedbackEnabled(): boolean {
return AppConfig.mailClientFeatures.feedbackEnabled; return AppConfig.mailClientFeatures.feedbackEnabled;
} }
/**
* Returns whether or not feedback features should be enabled
*/
export function announcementsEnabled(): boolean {
return AppConfig.announcements.enabled;
}
/** /**
* Returns whether or not dashboard features should be shown * Returns whether or not dashboard features should be shown
*/ */
...@@ -138,6 +146,25 @@ export function getCuratedTags(): string[] { ...@@ -138,6 +146,25 @@ export function getCuratedTags(): string[] {
return AppConfig.browse.curatedTags; return AppConfig.browse.curatedTags;
} }
/**
* Checks if nav links are active
*/
const isNavLinkActive = (link: LinkConfig): boolean => {
if (!announcementsEnabled()) {
return link.label !== ANNOUNCEMENTS_LINK_LABEL;
}
return true;
};
/*
* Returns the updated list of navigation links given the other
* configuration options state
*/
export function getNavLinks(): LinkConfig[] {
return AppConfig.navLinks.filter(isNavLinkActive);
}
/** /**
* Returns whether to enable the table `explore` feature * Returns whether to enable the table `explore` feature
*/ */
......
...@@ -105,6 +105,51 @@ describe('getBadgeConfig', () => { ...@@ -105,6 +105,51 @@ describe('getBadgeConfig', () => {
}); });
}); });
describe('getNavLinks', () => {
const testNavLinks = [
{
label: 'TestLabel1',
id: 'nav::testPage1',
href: '/testPage1',
use_router: true,
},
{
label: 'TestLabel2',
id: 'nav::testPage2',
href: '/testPage2',
use_router: true,
},
];
AppConfig.navLinks = [
...testNavLinks,
{
label: 'Announcements',
id: 'nav::announcements',
href: '/announcements',
use_router: true,
},
];
describe('when announcements is active', () => {
it('returns all the navLinks', () => {
const actual = ConfigUtils.getNavLinks();
const expected = AppConfig.navLinks;
expect(actual).toEqual(expected);
});
});
describe('when announcements is deactivated', () => {
it('returns all the navLinks but the announcements', () => {
AppConfig.announcements.enabled = false;
const actual = ConfigUtils.getNavLinks();
const expected = testNavLinks;
expect(actual).toEqual(expected);
});
});
});
describe('feedbackEnabled', () => { describe('feedbackEnabled', () => {
it('returns whether or not the feaadback feature is enabled', () => { it('returns whether or not the feaadback feature is enabled', () => {
expect(ConfigUtils.feedbackEnabled()).toBe( expect(ConfigUtils.feedbackEnabled()).toBe(
...@@ -113,6 +158,14 @@ describe('feedbackEnabled', () => { ...@@ -113,6 +158,14 @@ describe('feedbackEnabled', () => {
}); });
}); });
describe('announcementsEnabled', () => {
it('returns whether or not the announcements feature is enabled', () => {
expect(ConfigUtils.announcementsEnabled()).toBe(
AppConfig.announcements.enabled
);
});
});
describe('issueTrackingEnabled', () => { describe('issueTrackingEnabled', () => {
it('returns whether or not the issueTracking feature is enabled', () => { it('returns whether or not the issueTracking feature is enabled', () => {
expect(ConfigUtils.issueTrackingEnabled()).toBe( expect(ConfigUtils.issueTrackingEnabled()).toBe(
......
import axios, { AxiosResponse } from 'axios';
import { AnnouncementPost } from 'interfaces';
import * as API from './v0';
jest.mock('axios');
describe('getAnnouncements', () => {
let expectedPosts: AnnouncementPost[];
let mockResponse: AxiosResponse<API.AnnouncementsAPI>;
beforeAll(() => {
expectedPosts = [
{
date: '12/31/1999',
title: 'Test',
html_content: '<div>Test content</div>',
},
];
});
describe('when success', () => {
it('resolves with array of posts and status code', async () => {
expect.assertions(1);
mockResponse = {
data: {
posts: expectedPosts,
msg: 'Success',
},
status: 200,
statusText: '',
headers: {},
config: {},
};
// @ts-ignore: TypeScript errors on Jest mock methods unless we extend AxiosStatic for tests
axios.mockResolvedValue(mockResponse);
const expected = {
posts: expectedPosts,
statusCode: mockResponse.status,
};
await API.getAnnouncements().then((response) => {
expect(response).toEqual(expected);
});
});
});
describe('when error', () => {
it('catches error and resolves with object containing error code', async () => {
expect.assertions(1);
mockResponse = {
data: {
posts: [],
msg: 'A client for retrieving announcements must be configured',
},
status: 500,
statusText: '',
headers: {},
config: {},
};
// @ts-ignore: TypeScript errors on Jest mock methods unless we extend AxiosStatic for tests
axios.mockRejectedValue(mockResponse);
const expected = {
posts: [],
statusCode: mockResponse.status,
};
await API.getAnnouncements().catch((response) => {
expect(response).toEqual(expected);
});
});
});
afterAll(() => {
// @ts-ignore: TypeScript errors on Jest mock methods unless we extend AxiosStatic for tests
axios.mockClear();
});
});
import axios, { AxiosResponse } from 'axios';
import { AnnouncementPost } from 'interfaces';
import * as API from '../v0';
jest.mock('axios');
describe('getAnnouncements', () => {
let expectedPosts: AnnouncementPost[];
let mockResponse: AxiosResponse<API.AnnouncementsAPI>;
beforeAll(() => {
expectedPosts = [
{
date: '12/31/1999',
title: 'Test',
html_content: '<div>Test content</div>',
},
];
mockResponse = {
data: {
posts: expectedPosts,
msg: 'Success',
},
status: 200,
statusText: '',
headers: {},
config: {},
};
// @ts-ignore: TypeScript errors on Jest mock methods unless we extend AxiosStatic for tests
axios.mockResolvedValue(mockResponse);
});
it('resolves with array of posts from response.data on success', async () => {
expect.assertions(1);
await API.getAnnouncements().then((posts) => {
expect(posts).toEqual(expectedPosts);
});
});
afterAll(() => {
// @ts-ignore: TypeScript errors on Jest mock methods unless we extend AxiosStatic for tests
axios.mockClear();
});
});
...@@ -7,11 +7,30 @@ export type AnnouncementsAPI = { ...@@ -7,11 +7,30 @@ export type AnnouncementsAPI = {
posts: AnnouncementPost[]; posts: AnnouncementPost[];
}; };
const SERVER_ERROR_CODE = 500;
export function getAnnouncements() { export function getAnnouncements() {
return axios({ return axios({
method: 'get', method: 'get',
url: '/api/announcements/v0/', url: '/api/announcements/v0/',
}).then((response: AxiosResponse<AnnouncementsAPI>) => { })
return response.data.posts; .then((response: AxiosResponse<AnnouncementsAPI>) => {
}); const { data, status } = response;
return {
posts: data.posts,
statusCode: status,
};
})
.catch((e) => {
const { response } = e;
const statusCode = response
? response.status || SERVER_ERROR_CODE
: SERVER_ERROR_CODE;
return Promise.reject({
posts: [],
statusCode,
});
});
} }
import { expectSaga, testSaga } from 'redux-saga-test-plan'; import { testSaga } from 'redux-saga-test-plan';
import * as matchers from 'redux-saga-test-plan/matchers';
import { throwError } from 'redux-saga-test-plan/providers';
import * as API from '../api/v0'; import * as API from './api/v0';
import reducer, { import reducer, {
getAnnouncements, getAnnouncements,
getAnnouncementsFailure, getAnnouncementsFailure,
getAnnouncementsSuccess, getAnnouncementsSuccess,
initialState, initialState,
AnnouncementsReducerState, AnnouncementsReducerState,
} from '../reducer'; } from '.';
import { getAnnouncementsWatcher, getAnnouncementsWorker } from '../sagas'; import { getAnnouncementsWatcher, getAnnouncementsWorker } from './sagas';
import { GetAnnouncements } from '../types'; import { GetAnnouncements } from './types';
describe('announcements ducks', () => { const SERVER_ERROR_CODE = 500;
describe('Announcements ducks', () => {
describe('actions', () => { describe('actions', () => {
it('getAnnouncements - returns the action to get all tags', () => { it('getAnnouncements - returns the action to get all tags', () => {
const action = getAnnouncements(); const action = getAnnouncements();
...@@ -21,10 +21,16 @@ describe('announcements ducks', () => { ...@@ -21,10 +21,16 @@ describe('announcements ducks', () => {
}); });
it('getAnnouncementsFailure - returns the action to process failure', () => { it('getAnnouncementsFailure - returns the action to process failure', () => {
const action = getAnnouncementsFailure(); const expectedPayload = {
statusCode: SERVER_ERROR_CODE,
posts: [],
};
const action = getAnnouncementsFailure(expectedPayload);
const { payload } = action; const { payload } = action;
expect(action.type).toBe(GetAnnouncements.FAILURE); expect(action.type).toBe(GetAnnouncements.FAILURE);
expect(payload.posts).toEqual([]); expect(payload.posts).toEqual([]);
expect(payload.statusCode).toEqual(SERVER_ERROR_CODE);
}); });
it('getAllTagsSuccess - returns the action to process success', () => { it('getAllTagsSuccess - returns the action to process success', () => {
...@@ -35,8 +41,14 @@ describe('announcements ducks', () => { ...@@ -35,8 +41,14 @@ describe('announcements ducks', () => {
html_content: '<div>Test content</div>', html_content: '<div>Test content</div>',
}, },
]; ];
const action = getAnnouncementsSuccess(expectedPosts); const expectedPayload = {
posts: expectedPosts,
statusCode: 200,
};
const action = getAnnouncementsSuccess(expectedPayload);
const { payload } = action; const { payload } = action;
expect(action.type).toBe(GetAnnouncements.SUCCESS); expect(action.type).toBe(GetAnnouncements.SUCCESS);
expect(payload.posts).toBe(expectedPosts); expect(payload.posts).toBe(expectedPosts);
}); });
...@@ -44,34 +56,75 @@ describe('announcements ducks', () => { ...@@ -44,34 +56,75 @@ describe('announcements ducks', () => {
describe('reducer', () => { describe('reducer', () => {
let testState: AnnouncementsReducerState; let testState: AnnouncementsReducerState;
beforeAll(() => { beforeAll(() => {
testState = { testState = {
isLoading: false,
statusCode: 200,
posts: [], posts: [],
}; };
}); });
it('should return the existing state if action is not handled', () => {
expect(reducer(testState, { type: 'INVALID.ACTION' })).toEqual(testState); describe('when action is not handled', () => {
it('should return the existing state', () => {
const expected = testState;
const actual = reducer(testState, { type: 'INVALID.ACTION' });
expect(actual).toEqual(expected);
});
}); });
it('should handle GetAnnouncements.SUCCESS', () => { describe('when action is REQUEST', () => {
const expectedPosts = [ it('should handle GetAnnouncements.REQUEST', () => {
{ const expected = {
date: '12/31/1999', ...testState,
title: 'Test', isLoading: true,
html_content: '<div>Test content</div>', statusCode: null,
}, };
]; const actual = reducer(testState, getAnnouncements());
expect(
reducer(testState, getAnnouncementsSuccess(expectedPosts)) expect(actual).toEqual(expected);
).toEqual({
posts: expectedPosts,
}); });
}); });
it('should return the initialState if GetAnnouncements.FAILURE', () => { describe('when action is SUCCESS', () => {
expect(reducer(testState, getAnnouncementsFailure())).toEqual( it('should handle GetAnnouncements.SUCCESS', () => {
initialState const expectedPosts = [
); {
date: '12/31/1999',
title: 'Test',
html_content: '<div>Test content</div>',
},
];
const payload = {
posts: expectedPosts,
statusCode: 200,
};
const expected = {
isLoading: false,
statusCode: 200,
posts: expectedPosts,
};
const actual = reducer(testState, getAnnouncementsSuccess(payload));
expect(actual).toEqual(expected);
});
});
describe('when action is FAILURE', () => {
it('should return the initialState with the status code', () => {
const expected = {
...initialState,
isLoading: false,
statusCode: SERVER_ERROR_CODE,
};
const actual = reducer(
testState,
getAnnouncementsFailure({ statusCode: SERVER_ERROR_CODE })
);
expect(actual).toEqual(expected);
});
}); });
}); });
...@@ -87,27 +140,41 @@ describe('announcements ducks', () => { ...@@ -87,27 +140,41 @@ describe('announcements ducks', () => {
}); });
describe('getAnnouncementsWorker', () => { describe('getAnnouncementsWorker', () => {
it('gets posts', () => { it('executes flow for successfuly getting announcements', () => {
const mockPosts = [ const mockResponse = {
{ posts: [
date: '12/31/1999', {
title: 'Test', date: '12/31/1999',
html_content: '<div>Test content</div>', title: 'Test',
}, html_content: '<div>Test content</div>',
]; },
return expectSaga(getAnnouncementsWorker) ],
.provide([[matchers.call.fn(API.getAnnouncements), mockPosts]]) statusCode: 200,
.put(getAnnouncementsSuccess(mockPosts)) };
.run();
testSaga(getAnnouncementsWorker)
.next()
.call(API.getAnnouncements)
.next(mockResponse)
.put(getAnnouncementsSuccess(mockResponse))
.next()
.isDone();
}); });
it('handles request error', () => { it('executes flow for a failed request', () => {
return expectSaga(getAnnouncementsWorker) const mockResponse = {
.provide([ statusCode: SERVER_ERROR_CODE,
[matchers.call.fn(API.getAnnouncements), throwError(new Error())], statusMessage: 'Error',
]) };
.put(getAnnouncementsFailure())
.run(); testSaga(getAnnouncementsWorker)
.next()
.call(API.getAnnouncements)
// @ts-ignore
.throw(mockResponse)
.put(getAnnouncementsFailure(mockResponse))
.next()
.isDone();
}); });
}); });
}); });
......
...@@ -4,28 +4,38 @@ import { ...@@ -4,28 +4,38 @@ import {
GetAnnouncements, GetAnnouncements,
GetAnnouncementsRequest, GetAnnouncementsRequest,
GetAnnouncementsResponse, GetAnnouncementsResponse,
GetAnnouncementsPayload,
} from './types'; } from './types';
/* ACTIONS */ /* ACTIONS */
export function getAnnouncements(): GetAnnouncementsRequest { export function getAnnouncements(): GetAnnouncementsRequest {
return { type: GetAnnouncements.REQUEST }; return { type: GetAnnouncements.REQUEST };
} }
export function getAnnouncementsFailure(): GetAnnouncementsResponse { export function getAnnouncementsFailure(
return { type: GetAnnouncements.FAILURE, payload: { posts: [] } }; payload: GetAnnouncementsPayload
): GetAnnouncementsResponse {
return { type: GetAnnouncements.FAILURE, payload };
} }
export function getAnnouncementsSuccess( export function getAnnouncementsSuccess(
posts: AnnouncementPost[] payload: GetAnnouncementsPayload
): GetAnnouncementsResponse { ): GetAnnouncementsResponse {
return { type: GetAnnouncements.SUCCESS, payload: { posts } }; return {
type: GetAnnouncements.SUCCESS,
payload,
};
} }
/* REDUCER */ /* REDUCER */
export interface AnnouncementsReducerState { export interface AnnouncementsReducerState {
posts: AnnouncementPost[]; posts: AnnouncementPost[];
isLoading: boolean;
statusCode: number;
} }
export const initialState: AnnouncementsReducerState = { export const initialState: AnnouncementsReducerState = {
posts: [], posts: [],
isLoading: true,
statusCode: null,
}; };
export default function reducer( export default function reducer(
...@@ -33,10 +43,25 @@ export default function reducer( ...@@ -33,10 +43,25 @@ export default function reducer(
action action
): AnnouncementsReducerState { ): AnnouncementsReducerState {
switch (action.type) { switch (action.type) {
case GetAnnouncements.REQUEST:
return {
...state,
isLoading: true,
statusCode: null,
};
case GetAnnouncements.FAILURE: case GetAnnouncements.FAILURE:
return initialState; return {
...state,
isLoading: false,
statusCode: action.payload.statusCode,
};
case GetAnnouncements.SUCCESS: case GetAnnouncements.SUCCESS:
return { posts: (<GetAnnouncementsResponse>action).payload.posts }; return {
...state,
isLoading: false,
statusCode: action.payload.statusCode,
posts: (<GetAnnouncementsResponse>action).payload.posts,
};
default: default:
return state; return state;
} }
......
...@@ -2,15 +2,16 @@ import { SagaIterator } from 'redux-saga'; ...@@ -2,15 +2,16 @@ import { SagaIterator } from 'redux-saga';
import { call, put, takeEvery } from 'redux-saga/effects'; import { call, put, takeEvery } from 'redux-saga/effects';
import * as API from './api/v0'; import * as API from './api/v0';
import { getAnnouncementsFailure, getAnnouncementsSuccess } from './reducer'; import { getAnnouncementsFailure, getAnnouncementsSuccess } from '.';
import { GetAnnouncements } from './types'; import { GetAnnouncements } from './types';
export function* getAnnouncementsWorker(): SagaIterator { export function* getAnnouncementsWorker(): SagaIterator {
try { try {
const posts = yield call(API.getAnnouncements); const response = yield call(API.getAnnouncements);
yield put(getAnnouncementsSuccess(posts));
} catch (e) { yield put(getAnnouncementsSuccess(response));
yield put(getAnnouncementsFailure()); } catch (error) {
yield put(getAnnouncementsFailure(error));
} }
} }
export function* getAnnouncementsWatcher(): SagaIterator { export function* getAnnouncementsWatcher(): SagaIterator {
......
...@@ -5,12 +5,17 @@ export enum GetAnnouncements { ...@@ -5,12 +5,17 @@ export enum GetAnnouncements {
SUCCESS = 'amundsen/announcements/GET_SUCCESS', SUCCESS = 'amundsen/announcements/GET_SUCCESS',
FAILURE = 'amundsen/announcements/GET_FAILURE', FAILURE = 'amundsen/announcements/GET_FAILURE',
} }
export interface GetAnnouncementsRequest { export interface GetAnnouncementsRequest {
type: GetAnnouncements.REQUEST; type: GetAnnouncements.REQUEST;
} }
export interface GetAnnouncementsResponse { export interface GetAnnouncementsResponse {
type: GetAnnouncements.SUCCESS | GetAnnouncements.FAILURE; type: GetAnnouncements.SUCCESS | GetAnnouncements.FAILURE;
payload: { payload: GetAnnouncementsPayload;
posts: AnnouncementPost[]; }
};
export interface GetAnnouncementsPayload {
posts?: AnnouncementPost[];
statusCode?: number;
} }
...@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios'; ...@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { dashboardMetadata } from 'fixtures/metadata/dashboard'; import { dashboardMetadata } from 'fixtures/metadata/dashboard';
import * as API from '../api/v0'; import * as API from './v0';
jest.mock('axios'); jest.mock('axios');
......
...@@ -5,7 +5,7 @@ import reducer, { ...@@ -5,7 +5,7 @@ import reducer, {
getDashboardSuccess, getDashboardSuccess,
initialDashboardState, initialDashboardState,
DashboardReducerState, DashboardReducerState,
} from '../reducer'; } from './reducer';
describe('dashboard reducer', () => { describe('dashboard reducer', () => {
let testState: DashboardReducerState; let testState: DashboardReducerState;
......
import { testSaga } from 'redux-saga-test-plan'; import { testSaga } from 'redux-saga-test-plan';
import { dashboardMetadata } from 'fixtures/metadata/dashboard'; import { dashboardMetadata } from 'fixtures/metadata/dashboard';
import * as API from '../api/v0'; import * as API from './api/v0';
import * as Sagas from '../sagas'; import * as Sagas from './sagas';
import { import {
getDashboard, getDashboard,
getDashboardFailure, getDashboardFailure,
getDashboardSuccess, getDashboardSuccess,
} from '../reducer'; } from './reducer';
import { GetDashboard } from '../types'; import { GetDashboard } from './types';
describe('dashboard sagas', () => { describe('dashboard sagas', () => {
describe('getDashboardWatcher', () => { describe('getDashboardWatcher', () => {
......
...@@ -9,6 +9,7 @@ export function* getDashboardWorker(action): SagaIterator { ...@@ -9,6 +9,7 @@ export function* getDashboardWorker(action): SagaIterator {
try { try {
const { uri, searchIndex, source } = action.payload; const { uri, searchIndex, source } = action.payload;
const response = yield call(API.getDashboard, uri, searchIndex, source); const response = yield call(API.getDashboard, uri, searchIndex, source);
yield put(getDashboardSuccess(response)); yield put(getDashboardSuccess(response));
} catch (error) { } catch (error) {
yield put(getDashboardFailure(error)); yield put(getDashboardFailure(error));
......
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import dashboard, { DashboardReducerState } from 'ducks/dashboard/reducer'; import dashboard, { DashboardReducerState } from 'ducks/dashboard/reducer';
import announcements, { import announcements, { AnnouncementsReducerState } from './announcements';
AnnouncementsReducerState,
} from './announcements/reducer';
import feedback, { FeedbackReducerState } from './feedback/reducer'; import feedback, { FeedbackReducerState } from './feedback/reducer';
import popularTables, { import popularTables, {
PopularTablesReducerState, PopularTablesReducerState,
......
...@@ -7,6 +7,8 @@ import { dashboardMetadata } from './metadata/dashboard'; ...@@ -7,6 +7,8 @@ import { dashboardMetadata } from './metadata/dashboard';
const globalState: GlobalState = { const globalState: GlobalState = {
announcements: { announcements: {
isLoading: false,
statusCode: 200,
posts: [ posts: [
{ {
date: '12/31/1999', date: '12/31/1999',
......
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
"eslint-fix": "eslint --fix --ignore-path=.eslintignore --ext .js,.jsx,.ts,.tsx .", "eslint-fix": "eslint --fix --ignore-path=.eslintignore --ext .js,.jsx,.ts,.tsx .",
"test:watch": "cross-env TZ=UTC jest --watch", "test:watch": "cross-env TZ=UTC jest --watch",
"tsc": "tsc", "tsc": "tsc",
"clean-sass-vars": "find-unused-sass-variables ./js",
"stylelint": "stylelint '**/*.scss'", "stylelint": "stylelint '**/*.scss'",
"stylelint-fix": "stylelint --fix '**/*.scss'", "stylelint-fix": "stylelint --fix '**/*.scss'",
"format": "prettier --loglevel warn --write \"**/*.{ts,tsx,css,scss}\"", "format": "prettier --loglevel warn --write \"**/*.{ts,tsx,css,scss}\"",
...@@ -85,6 +86,7 @@ ...@@ -85,6 +86,7 @@
"eslint-plugin-prettier": "^3.1.3", "eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.20.5", "eslint-plugin-react": "^7.20.5",
"eslint-plugin-react-hooks": "^4.0.4", "eslint-plugin-react-hooks": "^4.0.4",
"find-unused-sass-variables": "^2.0.0",
"html-webpack-plugin": "4.3.0", "html-webpack-plugin": "4.3.0",
"husky": "^4.2.5", "husky": "^4.2.5",
"jest": "^25.5.4", "jest": "^25.5.4",
...@@ -516,7 +518,8 @@ ...@@ -516,7 +518,8 @@
"if", "if",
"each", "each",
"include", "include",
"mixin" "mixin",
"extend"
] ]
} }
] ]
......
...@@ -4,10 +4,19 @@ This document describes how to leverage the frontend service's application confi ...@@ -4,10 +4,19 @@ This document describes how to leverage the frontend service's application confi
**NOTE: This document is a work in progress and does not include 100% of features. We welcome PRs to complete this document** **NOTE: This document is a work in progress and does not include 100% of features. We welcome PRs to complete this document**
## Announcements Config
Annoncements is a feature that allows to disclose new features, changes or any other news to Amundsen's users.
<img src='img/announcements_feature.png' width='50%' />
To enable this feature, change the `announcements.enable` boolean value by overriding it on [config-custom.ts](https://github.com/amundsen-io/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config-custom.ts#L1). Once activated, an "Announcements" link will be available in the global navigation, and a new list of announcements will show up on the right sidebar on the Homepage.
## Badge Config ## Badge Config
Badges are a special type of tag that cannot be edited through the UI. Badges are a special type of tag that cannot be edited through the UI.
`BadgeConfig` can be used to customize the text and color of badges. This config defines a mapping of badge name to a `BadgeStyle` and optional `displayName`. Badges that are not defined will default to use the `BadgeStyle.default` style and `displayName` use the badge name with any `_` or `-` characters replaced with a space. `BadgeConfig` can be used to customize the text and color of badges. This config defines a mapping of badge name to a `BadgeStyle` and optional `displayName`. Badges that are not defined will default to use the `BadgeStyle.default` style and `displayName` use the badge name with any `_` or `-` characters replaced with a space.
## Browse Tags Feature ## Browse Tags Feature
...@@ -16,9 +25,10 @@ _TODO: Please add doc_ ...@@ -16,9 +25,10 @@ _TODO: Please add doc_
## Custom Logo ## Custom Logo
1. Add your logo to the folder in `amundsen_application/static/images/`. 1. Add your logo to the folder in `amundsen_application/static/images/`.
2. Set the the `logoPath` key on the to the location of your image. 2. Set the the `logoPath` key on the to the location of your image.
## Date ## Date
This config allows you to specify various date formats across the app. There are three date formats in use shown below. These correspond to the `formatDate`, `formatDateTimeShort` and `formatDateTimeLong` utility functions. This config allows you to specify various date formats across the app. There are three date formats in use shown below. These correspond to the `formatDate`, `formatDateTimeShort` and `formatDateTimeLong` utility functions.
default: 'MMM DD, YYYY' default: 'MMM DD, YYYY'
...@@ -27,28 +37,32 @@ This config allows you to specify various date formats across the app. There are ...@@ -27,28 +37,32 @@ This config allows you to specify various date formats across the app. There are
Reference for formatting: https://devhints.io/datetime#momentjs-format Reference for formatting: https://devhints.io/datetime#momentjs-format
## Google Analytics ## Google Analytics
_TODO: Please add doc_ _TODO: Please add doc_
## Indexing Optional Resources ## Indexing Optional Resources
In Amundsen, we currently support indexing other optional resources beyond tables. In Amundsen, we currently support indexing other optional resources beyond tables.
### Index Users ### Index Users
Users themselves are data resources and user metadata helps to facilitate network based discovery. When users are indexed they will show up in search results, and selecting a user surfaces a profile page that displays that user's relationships with different data resources. Users themselves are data resources and user metadata helps to facilitate network based discovery. When users are indexed they will show up in search results, and selecting a user surfaces a profile page that displays that user's relationships with different data resources.
After ingesting user metadata into the search and metadata services, set `IndexUsersConfig.enabled` to `true` on the application configuration to display the UI for the aforementioned features. After ingesting user metadata into the search and metadata services, set `IndexUsersConfig.enabled` to `true` on the application configuration to display the UI for the aforementioned features.
### Index Dashboards ### Index Dashboards
Introducing dashboards into Amundsen allows users to discovery data analysis that has been already done. When dashboards are indexed they will show up in search results, and selecting a dashboard surfaces a page where users can explore dashboard metadata. Introducing dashboards into Amundsen allows users to discovery data analysis that has been already done. When dashboards are indexed they will show up in search results, and selecting a dashboard surfaces a page where users can explore dashboard metadata.
After ingesting dashboard metadata into the search and metadata services, set `IndexDashboardsConfig.enabled` to `true` on the application configuration to display the UI for the aforementioned features. After ingesting dashboard metadata into the search and metadata services, set `IndexDashboardsConfig.enabled` to `true` on the application configuration to display the UI for the aforementioned features.
## Mail Client Features ## Mail Client Features
Amundsen has two features that leverage the custom mail client -- the feedback tool and notifications. Amundsen has two features that leverage the custom mail client -- the feedback tool and notifications.
As these are optional features, our `MailClientFeaturesConfig` can be used to hide/display any UI related to these features: As these are optional features, our `MailClientFeaturesConfig` can be used to hide/display any UI related to these features:
1. Set `MailClientFeaturesConfig.feedbackEnabled` to `true` in order to display the `Feedback` component in the UI. 1. Set `MailClientFeaturesConfig.feedbackEnabled` to `true` in order to display the `Feedback` component in the UI.
2. Set `MailClientFeaturesConfig.notificationsEnabled` to `true` in order to display the optional UI for users to request more information about resources on the `TableDetail` page. 2. Set `MailClientFeaturesConfig.notificationsEnabled` to `true` in order to display the optional UI for users to request more information about resources on the `TableDetail` page.
...@@ -60,24 +74,31 @@ client, please see this [entry](flask_config.md#mail-client-features) in our fla ...@@ -60,24 +74,31 @@ client, please see this [entry](flask_config.md#mail-client-features) in our fla
_TODO: Please add doc_ _TODO: Please add doc_
## Resource Configurations ## Resource Configurations
This configuration drives resource specific aspects of the application's user interface. Each supported resource should be mapped to an object that matches or extends the `BaseResourceConfig`. This configuration drives resource specific aspects of the application's user interface. Each supported resource should be mapped to an object that matches or extends the `BaseResourceConfig`.
### Base Configuration ### Base Configuration
All resource configurations must match or extend the `BaseResourceConfig`. This configuration supports the following options: All resource configurations must match or extend the `BaseResourceConfig`. This configuration supports the following options:
1. `displayName`: The name displayed throughout the application to refer to this resource type. 1. `displayName`: The name displayed throughout the application to refer to this resource type.
2. `filterCategories`: An optional `FilterConfig` object. When set for a given resource, that resource will display filter options in the search page UI. 2. `filterCategories`: An optional `FilterConfig` object. When set for a given resource, that resource will display filter options in the search page UI.
3. `supportedSources`: An optional `SourcesConfig` object. 3. `supportedSources`: An optional `SourcesConfig` object.
#### Filter Categories #### Filter Categories
The `FilterConfig` is an array of objects that match any of the supported filter options. We currently support a `MultiSelectFilterCategory` and a `SingleFilterCategory`. See our [config-types](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config-types.ts) for more information about each option. The `FilterConfig` is an array of objects that match any of the supported filter options. We currently support a `MultiSelectFilterCategory` and a `SingleFilterCategory`. See our [config-types](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config-types.ts) for more information about each option.
#### Supported Sources #### Supported Sources
The `SourcesConfig` can be used for the customizations detailed below. See examples in [config-default.ts](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config-default.ts). The `SourcesConfig` can be used for the customizations detailed below. See examples in [config-default.ts](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/js/config/config-default.ts).
##### Custom Icons ##### Custom Icons
You can configure custom icons to be used throughout the UI when representing entities from particular sources. On the `supportedSources` object, add an entry with the `id` used to reference that source and map to an object that specifies the `iconClass` for that database. This `iconClass` should be defined in [icons.scss](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/css/_icons.scss). You can configure custom icons to be used throughout the UI when representing entities from particular sources. On the `supportedSources` object, add an entry with the `id` used to reference that source and map to an object that specifies the `iconClass` for that database. This `iconClass` should be defined in [icons.scss](https://github.com/lyft/amundsenfrontendlibrary/blob/master/amundsen_application/static/css/_icons.scss).
##### Display Names ##### Display Names
You can configure a specific display name to be used throughout the UI when representing entities from particular sources. On the `supportedSources` object, add an entry with the `id` used to reference that source and map to an object that specified the `displayName` for that source. You can configure a specific display name to be used throughout the UI when representing entities from particular sources. On the `supportedSources` object, add an entry with the `id` used to reference that source and map to an object that specified the `displayName` for that source.
## Table Lineage ## Table Lineage
...@@ -86,8 +107,9 @@ _TODO: Please add doc_ ...@@ -86,8 +107,9 @@ _TODO: Please add doc_
## Table Profile ## Table Profile
_TODO: Please add doc*_ _TODO: Please add doc\*_
## Issue Tracking Features ## Issue Tracking Features
In order to enable Issue Tracking set `IssueTrackingConfig.enabled` to `true` to see UI features. Further configuration In order to enable Issue Tracking set `IssueTrackingConfig.enabled` to `true` to see UI features. Further configuration
is required to fully enable the feature, please see this [entry](flask_config.md#issue-tracking-integration-features) is required to fully enable the feature, please see this [entry](flask_config.md#issue-tracking-integration-features)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment