@material-ui/core#WithWidthProps TypeScript Examples
The following examples show how to use
@material-ui/core#WithWidthProps.
You can vote up the ones you like or vote down the ones you don't like,
and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: Stack.tsx From clearflask with Apache License 2.0 | 5 votes |
class Stack extends Component<Props & WithStyles<typeof styles, true> & WithWidthProps, State> {
state: State = {};
render() {
const count = this.props.items.length;
var overlap = true;
var spacing = 100;
switch (this.props.width) {
case "xs":
overlap = false;
break;
case "sm":
spacing = 150
break;
case "md":
spacing = 50
break;
default:
break;
}
var spacingHor = this.props.contentSpacingHorizontal || spacing;
var spacingVer = this.props.contentSpacingVertical || spacing;
var marginTopBottom = overlap ? spacingVer * ((count - 1) / 2) : 0;
var marginLeftRight = overlap ? spacingHor * ((count - 1) / 2) : 0;
return (
<div className={this.props.classes.container} style={{
marginTop: marginTopBottom,
marginBottom: marginTopBottom,
marginLeft: marginLeftRight,
marginRight: marginLeftRight,
height: overlap ? 300 : undefined,
float: this.props.float,
}}>
{this.props.items.map((item, contentNumber) => (
<Paper
key={contentNumber}
className={this.props.classes.content}
onMouseOver={this.props.raiseOnHover && this.state.hoveringContent !== contentNumber ? () => this.setState({ hoveringContent: contentNumber }) : undefined}
style={{
height: item.height || 300,
width: item.width,
marginBottom: overlap ? 0 : 40,
position: overlap ? 'absolute' : 'static',
left: overlap ? spacingHor * ((count - 1) / 2 - contentNumber) : 0,
top: overlap ? -spacingVer * ((count - 1) / 2 - contentNumber) * (this.props.topLeftToBottomRight ? -1 : 1) : 0,
zIndex: this.state.hoveringContent === contentNumber ? 900 : 800 + contentNumber * (this.props.ascendingLevel ? -1 : 1),
}}
>
{item.content}
</Paper>
))}
</div>
);
}
}
Example #2
Source File: HorizontalPanels.tsx From clearflask with Apache License 2.0 | 5 votes |
class HorizontalPanels extends Component<Props & WithStyles<typeof styles, true> & WithWidthProps> {
render() {
const isHorizontal = this.props.alwaysWrap ? false : (!this.props.width || !this.props.wrapBelow || isWidthUp(this.props.wrapBelow, this.props.width));
const padLeftSize = isHorizontal && this.props.padLeft || 0;
const padRightSize = isHorizontal && this.props.padRight || 0;
const childrenSize = React.Children.count(this.props.children)
const contentsSize = childrenSize + padLeftSize + padRightSize;
const childrenMapper: (mapper: (content: React.ReactNode, index: number) => React.ReactNode) => React.ReactNode = (mapper) => {
return (
<>
{isHorizontal ? [...Array(padLeftSize)].map((c, i) => mapper((<div />), i)) : null}
{React.Children.map(this.props.children, (c, i) => mapper(c, (isHorizontal ? padLeftSize : 0) + i))}
{isHorizontal ? [...Array(padRightSize)].map((c, i) => mapper((<div />), padLeftSize + childrenSize + i)) : undefined}
</>
);
}
const staggerHeight = Math.abs(this.props.staggerHeight || 0);
const staggerAsc = (this.props.staggerHeight || 0) < 0;
const childrenCount = React.Children.count(this.props.children);
return (
<Container
className={this.props.classes.container}
maxWidth={this.props.maxWidth}
style={{
flexDirection: isHorizontal ? 'row' : (staggerAsc ? 'column-reverse' : 'column'),
}}
>
{childrenMapper((content, index) => {
if (!content) return null;
var leftPads = index;
var rightPads = contentsSize - index - 1;
return (
<div
key={content?.['key'] || index}
className={this.props.classes.content}
style={isHorizontal ? {
marginTop: staggerAsc
? (childrenCount - index - 1) * staggerHeight
: index * staggerHeight
} : undefined}
>
{[...Array(leftPads)].map((u, i) => (<div key={`left-${i}`} />))}
<Container maxWidth={this.props.maxContentWidth} style={{
margin: 'unset',
}}>
{content}
</Container>
{[...Array(rightPads)].map((u, i) => (<div key={`right-${i}`} />))}
</div>
)
}) || {}}
</Container>
);
}
}
Example #3
Source File: ExplorerTemplate.tsx From clearflask with Apache License 2.0 | 4 votes |
class ExplorerTemplate extends Component<Props & WithStyles<typeof styles, true> & RouteComponentProps & WithWidthProps, State> {
constructor(props) {
super(props);
this.state = {
hasExpanded: props.createShown,
};
}
render() {
const expandDirectionHorizontal = !this.props.isDashboard && (!this.props.width || isWidthUp('sm', this.props.width, true));
const labelContainer = (
<Collapse in={this.props.similarShown}>
<div className={this.props.classes.similarLabel}>
{this.props.similarLabel}
</div>
</Collapse>
);
const createVisible = !!this.props.createVisible && (
<div className={this.props.classes.createVisible} style={{
minWidth: this.props.createSize,
width: this.props.createSize,
}}>
{this.props.createVisible}
</div>
);
const createCollapsible = !!this.props.createCollapsible && (
<div
className={this.props.classes.createCollapsible}
style={{
width: this.props.createShown ? this.props.createSize : '0px',
maxWidth: this.props.createShown ? this.props.createSize : '0px',
}}
>
<Collapse
in={this.props.createShown || false}
mountOnEnter
unmountOnExit
onEntered={() => this.setState({ hasExpanded: true })}
onExited={() => this.setState({ hasExpanded: false })}
timeout={this.props.theme.explorerExpandTimeout}
style={{
minWidth: '120px',
}}
>
<div className={classNames(!expandDirectionHorizontal && this.props.classes.createCollapsibleVertical)}>
{this.props.createCollapsible}
</div>
{!expandDirectionHorizontal && this.props.similarLabel && labelContainer}
</Collapse>
</div>
);
const search = !!this.props.search && (
<Collapse in={!this.props.similarShown}>
<div className={this.props.classes.searchContainer}>
<div className={this.props.classes.search}>
{this.props.search}
</div>
</div>
</Collapse>
);
var results = this.props.content;
if (!!this.props.search || !!this.props.createVisible) {
results = (
<DividerCorner
isExplorer
width={!this.props.createVisible
? 0
: (this.props.createShown
? (this.props.similarShown ? 80 : 50)
: (this.props.createSize || 0))}
height={(!this.props.createVisible || !!this.props.isDashboard)
? 0
: (this.props.createShown ? 180 : 50)}
widthRight={this.props.searchSize !== undefined
? (this.props.similarShown
? 0
: this.props.searchSize)
: undefined}
heightRight={!!this.props.isDashboard
? 0
: (!!this.props.search
? (this.props.similarShown ? 0 : 50)
: undefined)}
header={!!expandDirectionHorizontal ? undefined : (
<>
{createVisible}
{createCollapsible}
</>
)}
headerRight={!!expandDirectionHorizontal ? undefined : search}
grow={this.props.isDashboard ? 'left' : 'center'}
margins={this.props.theme.spacing(4)}
>
{results}
</DividerCorner >
);
}
return (
<div className={classNames(this.props.classes.explorer, this.props.className, !!this.props.isDashboard && this.props.classes.dashboard)}>
<div className={this.props.classes.top}>
{!!expandDirectionHorizontal && createVisible}
{expandDirectionHorizontal && this.props.similarLabel && labelContainer}
<div className={this.props.classes.flexGrow} />
{!!expandDirectionHorizontal && search}
</div>
{!!expandDirectionHorizontal && createCollapsible}
<div className={this.props.classes.results}>
{results}
</div>
</div>
);
}
}
Example #4
Source File: IdeaExplorer.tsx From clearflask with Apache License 2.0 | 4 votes |
// class QueryState {
// search: QueryParamConfig<Partial<Client.IdeaSearch>> = {
// encode: () => string;
// decode: () => ;
// };
// }
// const styles: (theme: Theme) => Record<"tab Root", CSSProperties | CreateCSSProperties<{}> | PropsFunc<{}, CreateCSSProperties<{}>>>
// const styles: (theme: Theme) => Record<"createButtonClickable", CSSProperties | CreateCSSProperties<(value: JSSFontface, index: number, array: JSSFontface[]) => unknown> | PropsFunc<...>>
class IdeaExplorer extends Component<Props & ConnectProps & WithTranslation<'app'> & WithStyles<typeof styles, true> & RouteComponentProps & WithWidthProps, State> {
state: State = {};
readonly titleInputRef: React.RefObject<HTMLInputElement> = React.createRef();
readonly inViewObserverRef = React.createRef<InViewObserver>();
_isMounted: boolean = false;
readonly richEditorImageUploadRef = React.createRef<RichEditorImageUpload>();
constructor(props) {
super(props);
props.callOnMount?.();
}
componentDidMount() {
this._isMounted = true;
if (!!this.props.settings.demoCreateAnimate) {
this.demoCreateAnimate(
this.props.settings.demoCreateAnimate.title,
this.props.settings.demoCreateAnimate.description,
this.props.settings.demoCreateAnimate.similarSearchTerm,
);
}
}
componentWillUnmount() {
this._isMounted = false;
}
render() {
const isLarge = !!this.props.isDashboard;
const createShown = !!this.state.createOpen
|| (!this.props.settings.demoDisableExplorerExpanded
&& !this.props.isDashboard
&& this.props.width && isWidthUp('md', this.props.width));
const similarShown = createShown && !!this.state.searchSimilar;
const search = this.props.explorer.allowSearch && (
<PanelSearch
className={this.props.classes.panelSearch}
server={this.props.server}
search={this.state.search}
onSearchChanged={search => this.setState({ search: search })}
explorer={this.props.explorer}
showInitialBorder={!!this.props.isDashboard}
/>
);
const similarLabel = (
<Typography variant='overline' className={this.props.classes.caption}>
Similar
</Typography>
);
var content;
if (similarShown) {
const searchOverride = this.state.searchSimilar ? { searchText: this.state.searchSimilar } : undefined;
content = (
<div className={this.props.classes.content}>
<PanelPost
direction={Direction.Vertical}
panel={this.props.explorer}
searchOverride={searchOverride}
widthExpand
server={this.props.server}
onClickPost={this.props.onClickPost}
onUserClick={this.props.onUserClick}
suppressPanel
displayDefaults={{
titleTruncateLines: 1,
descriptionTruncateLines: 2,
responseTruncateLines: 0,
showCommentCount: false,
showCategoryName: false,
showCreated: false,
showAuthor: false,
showStatus: false,
showTags: false,
showVoting: false,
showVotingCount: false,
showFunding: false,
showExpression: false,
}} />
</div>
);
} else {
content = (
<div className={this.props.classes.content}>
<PanelPost
server={this.props.server}
direction={Direction.Vertical}
widthExpand={!this.props.isDashboard}
onClickPost={this.props.onClickPost}
onUserClick={this.props.onUserClick}
panel={this.props.explorer}
suppressPanel
displayDefaults={{
titleTruncateLines: 1,
descriptionTruncateLines: 2,
responseTruncateLines: 2,
showCommentCount: true,
showCreated: true,
showAuthor: true,
showVoting: false,
showVotingCount: true,
showFunding: true,
showExpression: true,
}}
searchOverride={this.state.search}
/>
</div>
);
}
const createVisible = !!this.props.explorer.allowCreate && (
<div
className={classNames(
this.props.classes.createButton,
!createShown && this.props.classes.createButtonClickable,
!!this.props.isDashboard && this.props.classes.createButtonShowBorder,
!!this.props.isDashboard && this.props.classes.createButtonDashboard,
)}
onClick={createShown ? undefined : e => {
this.setState({ createOpen: !this.state.createOpen });
this.titleInputRef.current?.focus();
}}
>
<Typography noWrap>
{this.props.t(createShown
? (this.props.explorer.allowCreate.actionTitleLong as any || this.props.explorer.allowCreate.actionTitle as any || 'add-new-post')
: (this.props.explorer.allowCreate.actionTitle as any || 'add'))}
</Typography>
<AddIcon
fontSize='small'
className={this.props.classes.addIcon}
/>
</div>
);
const createCollapsible = !!this.props.explorer.allowCreate && (
<>
<PostCreateForm
server={this.props.server}
type={isLarge ? 'large' : 'regular'}
mandatoryTagIds={this.props.explorer.search.filterTagIds}
mandatoryCategoryIds={this.props.explorer.search.filterCategoryIds}
adminControlsDefaultVisibility={this.props.createFormAdminControlsDefaultVisibility || (this.props.isDashboard ? 'expanded' : 'hidden')}
titleInputRef={this.titleInputRef}
searchSimilar={(text, categoryId) => this.setState({ searchSimilar: text })}
logInAndGetUserId={() => new Promise<string>(resolve => this.setState({ onLoggedIn: resolve }))}
onCreated={postId => {
if (this.props.onClickPost) {
this.props.onClickPost(postId);
} else {
this.props.history.push(preserveEmbed(`/post/${postId}`));
}
}}
defaultTitle={this.state.animateTitle}
defaultDescription={this.state.animateDescription}
/>
<LogIn
actionTitle={this.props.t('get-notified-of-replies')}
server={this.props.server}
open={!!this.state.onLoggedIn}
onClose={() => this.setState({ onLoggedIn: undefined })}
onLoggedInAndClose={userId => {
if (this.state.onLoggedIn) {
this.state.onLoggedIn(userId);
this.setState({ onLoggedIn: undefined });
}
}}
/>
</>
);
return (
<InViewObserver ref={this.inViewObserverRef} disabled={!this.props.settings.demoCreateAnimate}>
<ExplorerTemplate
className={classNames(
this.props.className,
this.props.classes.root,
!this.props.isDashboard && this.props.classes.fitContent)}
isDashboard={this.props.isDashboard}
createSize={this.props.explorer.allowCreate
? (createShown
? (isLarge
? 468 : 260)
: 116)
: 0}
createShown={createShown}
similarShown={similarShown}
similarLabel={similarLabel}
createVisible={createVisible}
createCollapsible={createCollapsible}
searchSize={this.props.explorer.allowSearch ? 120 : undefined}
search={search}
content={content}
/>
</InViewObserver>
);
}
async demoCreateAnimate(title: string, description?: string, searchTerm?: string) {
const animate = animateWrapper(
() => this._isMounted,
this.inViewObserverRef,
() => this.props.settings,
this.setState.bind(this));
if (await animate({ sleepInMs: 1000 })) return;
for (; ;) {
if (await animate({
setState: {
createOpen: true,
...(searchTerm ? { newItemSearchText: searchTerm } : {})
}
})) return;
if (await animate({ sleepInMs: 500 })) return;
for (var i = 0; i < title.length; i++) {
const character = title[i];
if (await animate({
sleepInMs: 10 + Math.random() * 30,
setState: { animateTitle: (this.state.animateTitle || '') + character },
})) return;
}
if (description !== undefined) {
if (await animate({ sleepInMs: 200 })) return;
for (var j = 0; j < description.length; j++) {
if (await animate({
sleepInMs: 10 + Math.random() * 30,
setState: { animateDescription: textToHtml(description.substr(0, j + 1)) },
})) return;
}
}
if (await animate({ sleepInMs: 500 })) return;
if (description !== undefined) {
for (var k = 0; k < description.length; k++) {
if (await animate({
sleepInMs: 5,
setState: { animateDescription: textToHtml(description.substr(0, description.length - k - 1)) },
})) return;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
while (this.state.animateTitle !== undefined && this.state.animateTitle.length !== 0) {
if (await animate({
sleepInMs: 5,
setState: { animateTitle: this.state.animateTitle.substr(0, this.state.animateTitle.length - 1) },
})) return;
}
if (await animate({ setState: { createOpen: false } })) return;
if (await animate({ sleepInMs: 1500 })) return;
}
}
}
Example #5
Source File: PanelSearch.tsx From clearflask with Apache License 2.0 | 4 votes |
class PanelSearch extends Component<Props & ConnectProps & WithStyles<typeof styles, true> & WithWidthProps, State> {
state: State = {};
_isMounted: boolean = false;
readonly updateSearchText = debounce(
(searchText?: string) => {
if (!isFilterControllable(this.props.explorer, PostFilterType.Search)) return;
this.props.onSearchChanged({
...this.props.search,
searchText: searchText,
});
},
SearchTypeDebounceTime);
readonly inViewObserverRef = React.createRef<InViewObserver>();
componentDidMount() {
this._isMounted = true;
if (!!this.props.settings.demoSearchAnimate) {
this.demoSearchAnimate(this.props.settings.demoSearchAnimate);
}
}
render() {
const controls = postSearchToLabels(this.props.config, this.props.explorer, this.props.search);
const isSearchable = isFilterControllable(this.props.explorer, PostFilterType.Search);
return (
<InViewObserver ref={this.inViewObserverRef} disabled={!this.props.settings.demoSearchAnimate}>
<div className={`${this.props.classes.container} ${this.props.className || ''}`} style={this.props.style}>
<SelectionPicker
value={controls.values}
menuIsOpen={!!this.state.menuIsOpen}
menuOnChange={open => this.setState({ menuIsOpen: open })}
inputValue={this.state.searchValue || ''}
onInputChange={(newValue, reason) => {
this.setState({ searchValue: newValue });
if (isSearchable) {
this.updateSearchText(newValue);
}
}}
placeholder={this.props.placeholder || 'Search'}
options={controls.options}
isMulti
group
isInExplorer
width={100}
autocompleteClasses={{
inputRoot: this.props.showInitialBorder ? undefined : this.props.classes.inputRootHideBorder,
}}
showTags={false}
disableFilter
disableCloseOnSelect
disableClearOnValueChange
onValueChange={labels => {
const partialSearch = postLabelsToSearch(labels.map(l => l.value));
this.props.onSearchChanged(partialSearch);
}}
formatHeader={inputValue => !!inputValue ? `Searching for "${inputValue}"` : `Type to search`}
dropdownIcon={FilterIcon}
popupColumnCount={minmax(
1,
controls.groups,
!this.props.width || isWidthUp('sm', this.props.width, true) ? 3 : 2)}
PopperProps={{ placement: 'bottom-end' }}
/>
</div>
</InViewObserver>
);
}
async demoSearchAnimate(searchTerms: Array<{
term: string;
update: Partial<Client.IdeaSearch>;
}>) {
const animate = animateWrapper(
() => this._isMounted,
this.inViewObserverRef,
() => this.props.settings,
this.setState.bind(this));
if (await animate({ sleepInMs: 1000 })) return;
for (; ;) {
for (const searchTerm of searchTerms) {
if (await animate({ sleepInMs: 150, setState: { menuIsOpen: true } })) return;
if (await animate({ sleepInMs: 2000 })) return;
for (var i = 0; i < searchTerm.term.length; i++) {
const term = searchTerm.term[i];
if (await animate({
sleepInMs: 10 + Math.random() * 30,
setState: { searchValue: (this.state.searchValue || '') + term, menuIsOpen: true }
})) return;
}
if (await animate({ sleepInMs: 2000, setState: { searchValue: '', menuIsOpen: undefined } })) return;
this.props.onSearchChanged({ ...this.props.search, ...searchTerm.update });
}
if (await animate({ sleepInMs: 300 })) return;
this.props.onSearchChanged({});
}
}
}
Example #6
Source File: PostConnectDialog.tsx From clearflask with Apache License 2.0 | 4 votes |
class PostConnectDialog extends Component<Props & WithWidthProps & WithStyles<typeof styles, true>, State> {
constructor(props) {
super(props);
this.state = {
action: 'link',
directionReversed: !!this.props.onlyAllowLinkFrom,
search: this.props.defaultSearch,
};
}
render() {
const isMobile = !!this.props.width && isWidthDown('sm', this.props.width);
const searchArea = this.renderSearchArea(isMobile);
const header = this.renderHeader();
const controls = this.renderControls();
var dialogContent;
if (!this.props.onlyAllowLinkFrom) {
dialogContent = (
<>
{isMobile && (
<Divider />
)}
{searchArea}
{!isMobile && (
<DividerVertical className={this.props.classes.visualAndSearchDivider} />
)}
<div className={this.props.classes.visualArea}>
{header}
{this.renderActionArea(isMobile)}
{controls}
</div>
</>
);
} else {
dialogContent = (
<div className={this.props.classes.onlyLinkContainer}>
{header}
<Divider />
{searchArea}
<Divider />
{controls}
</div>
);
}
return (
<Dialog
open={this.props.open}
fullWidth={!this.props.onlyAllowLinkFrom}
fullScreen={isMobile && !this.props.onlyAllowLinkFrom}
scroll={isMobile ? 'paper' : undefined}
maxWidth={isMobile ? 'xs' : 'md'}
onClose={() => this.props.onClose()}
classes={{
scrollPaper: this.props.classes.dialogPaper,
paper: classNames(
this.props.classes.content,
isMobile && this.props.classes.contentMobile,
),
}}
>
{dialogContent}
</Dialog>
);
}
renderSearchArea(isMobile: boolean): React.ReactNode {
const search = this.state.search || {
limit: 4,
similarToIdeaId: this.props.post?.ideaId,
};
const filters = this.renderSearchFilters(search, isMobile);
return (
<div className={classNames(
this.props.classes.searchArea,
this.props.onlyAllowLinkFrom && this.props.classes.searchAreaOnlyLinkContainer,
)}>
{!isMobile && (
<>
<div className={this.props.classes.filtersExternal}>{filters}</div>
<DividerVertical />
</>
)}
<div className={classNames(
(isMobile && !this.props.onlyAllowLinkFrom) ? this.props.classes.searchResultMobile : this.props.classes.searchResultScroll,
)}>
{this.renderSearchBar(search, isMobile ? filters : undefined)}
{this.renderSearchResult(search)}
</div>
</div>
);
}
renderSearchBarResult(isMobile: boolean): React.ReactNode {
return (
<>
</>
);
}
renderActionArea(isMobile: boolean): React.ReactNode {
const our = this.renderPostPreview(isMobile, this.props.post);
const actions = this.renderActions();
const other = this.renderPostPreview(isMobile, this.state.selectedPostId ? this.props.server.getStore().getState().ideas.byId[this.state.selectedPostId]?.idea : undefined);
return (
<div className={this.props.classes.actionArea}>
{!this.state.directionReversed
? [our, actions, other]
: [other, actions, our]}
</div>
);
}
renderControls(): React.ReactNode {
return (
<div className={this.props.classes.controlsArea}>
<DialogActions>
<Button onClick={() => this.props.onClose()}>
Cancel
</Button>
<SubmitButton
disableElevation
color='primary'
variant='contained'
disabled={!this.state.selectedPostId}
isSubmitting={this.state.isSubmitting}
onClick={async () => {
if (!this.state.selectedPostId) return;
if (this.props.onSubmit) {
this.props.onSubmit(this.state.selectedPostId, this.state.action, !!this.state.directionReversed);
return;
}
if (!this.props.post || !this.props.server) return;
this.setState({ isSubmitting: true });
try {
const projectId = this.props.server.getProjectId();
const ideaId = this.state.directionReversed ? this.state.selectedPostId : this.props.post.ideaId;
const parentIdeaId = this.state.directionReversed ? this.props.post.ideaId : this.state.selectedPostId;
const dispatcher = await this.props.server.dispatchAdmin();
await this.state.action === 'link'
? dispatcher.ideaLinkAdmin({ projectId, ideaId, parentIdeaId })
: dispatcher.ideaMergeAdmin({ projectId, ideaId, parentIdeaId });
this.props.onClose();
this.setState({ selectedPostId: undefined });
} finally {
this.setState({ isSubmitting: false });
}
}}
>
Apply
</SubmitButton>
</DialogActions>
</div>
);
}
renderHeader(): React.ReactNode {
return (
<div className={this.props.classes.headerArea}>
<DialogTitle>Connect post</DialogTitle>
</div>
);
}
renderPostPreview(isMobile: boolean, post?: Client.Idea): React.ReactNode {
return (
<div className={classNames(
this.props.classes.preview,
)}>
<OutlinePostContent key={post?.ideaId || 'unselected'} className={this.props.classes.previewOutline}>
{post ? (
<Post
variant='list'
server={this.props.server}
idea={post}
disableOnClick
display={display}
/>
) : (
<div className={this.props.classes.nothing}>
{isMobile ? 'Search and select below' : 'Select a post'}
</div>
)}
</OutlinePostContent>
</div>
);
}
renderActions(): React.ReactNode {
return (
<div key='link' className={this.props.classes.link}>
<div className={classNames(
this.props.classes.center,
this.props.classes.evenItem,
)}>
<IconButton
color='primary'
className={classNames(
this.props.classes.actionSwapDirection,
)}
onClick={() => this.setState({ directionReversed: !this.state.directionReversed })}
>
<SwapVertIcon fontSize='inherit' color='inherit' />
</IconButton>
</div>
<div className={this.props.classes.actionSelectedContainer}>
{this.renderAction(this.state.action, true)}
</div>
<IconButton
color='primary'
className={classNames(
this.props.classes.actionSwapType,
)}
onClick={() => this.setState({ action: this.state.action === 'link' ? 'merge' : 'link' })}
>
<ChangeIcon fontSize='inherit' color='inherit' />
</IconButton>
</div >
);
}
renderAction(type: 'link' | 'merge', selected: boolean = false): React.ReactNode {
return (
<div className={classNames(
this.props.classes.action,
selected && this.props.classes.actionSelected,
!selected && this.props.classes.actionNotSelected,
)} >
{type === 'link'
? (<LinkAltIcon fontSize='inherit' color='inherit' />)
: (<MergeIcon fontSize='inherit' color='inherit' className={this.props.classes.mergeIcon} />)}
{type === 'link'
? 'Link'
: 'Merge'}
<HelpPopper description={type === 'link'
? 'Shows a link between two related posts. Typically used for linking related feedback to tasks or completed tasks to an announcement.'
: 'Merges one post to another including all comments, votes and subscribers. Typically used for merging duplicate or similar posts together.'}
/>
</div>
);
}
renderSearchResult(search: Partial<Admin.IdeaSearchAdmin>): React.ReactNode {
return (
<PostList
server={this.props.server}
selectable='highlight'
selected={this.state.selectedPostId}
search={this.state.search}
onClickPost={postId => this.setState({ selectedPostId: this.state.selectedPostId === postId ? undefined : postId })}
displayOverride={display}
PanelPostProps={{
// Prevent linking/merging into itself
hideSearchResultPostIds: new Set([
this.props.post?.ideaId,
...(this.props.post?.mergedPostIds || []),
this.props.post?.mergedToPostId,
...(this.props.post?.linkedToPostIds || []),
...(this.props.post?.linkedFromPostIds || []),
].filter(notEmpty)),
}}
/>
);
}
renderSearchFilters(search: Partial<Admin.IdeaSearchAdmin>, isInsideSearch: boolean): React.ReactNode {
return (
<DashboardPostFilterControls
server={this.props.server}
search={search}
onSearchChanged={newSearch => this.setState({
search: {
...newSearch,
similarToIdeaId: undefined,
}
})}
allowSearchMultipleCategories
sortByDefault={Admin.IdeaSearchAdminSortByEnum.Trending}
horizontal={isInsideSearch}
/>
);
}
renderSearchBar(search: Partial<Admin.IdeaSearchAdmin>, filters?: React.ReactNode): React.ReactNode {
return (
<div className={this.props.classes.searchBar}>
<DashboardSearchControls
placeholder='Search'
searchText={search.searchText || ''}
onSearchChanged={searchText => this.setState({
search: {
...this.state.search,
searchText,
similarToIdeaId: undefined,
}
})}
filters={filters}
/>
</div>
);
}
}
Example #7
Source File: PostCreateForm.tsx From clearflask with Apache License 2.0 | 4 votes |
class PostCreateForm extends Component<Props & ConnectProps & WithStyles<typeof styles, true> & RouteComponentProps & WithWidthProps & WithSnackbarProps, State> {
readonly panelSearchRef: React.RefObject<any> = React.createRef();
readonly searchSimilarDebounced?: (title?: string, categoryId?: string) => void;
externalSubmitEnabled: boolean = false;
readonly richEditorImageUploadRef = React.createRef<RichEditorImageUpload>();
constructor(props) {
super(props);
this.state = {
adminControlsExpanded: props.adminControlsDefaultVisibility === 'expanded',
};
this.searchSimilarDebounced = !props.searchSimilar ? undefined : debounce(
(title?: string, categoryId?: string) => !!title && this.props.searchSimilar?.(title, categoryId),
this.props.type === 'post' ? SimilarTypeDebounceTime : SearchTypeDebounceTime);
if (this.props.externalControlRef) {
this.props.externalControlRef.current = {
subscription: new Subscription({}),
update: draftUpdate => this.setState(draftUpdate),
};
}
}
shouldComponentUpdate = customShouldComponentUpdate({
nested: new Set(['mandatoryTagIds', 'mandatoryCategoryIds']),
presence: new Set(['externalSubmit', 'searchSimilar', 'logInAndGetUserId', 'onCreated', 'onDraftCreated', 'callOnMount']),
});
componentDidMount() {
this.props.callOnMount?.();
}
render() {
// Merge defaults, server draft, and local changes into one draft
const draft: Draft = {
authorUserId: this.props.loggedInUserId,
title: this.props.defaultTitle,
description: this.props.defaultDescription,
statusId: this.props.defaultStatusId,
tagIds: [],
...this.props.draft,
draftId: this.props.draftId
};
const showModOptions = this.showModOptions();
const categoryOptions = (this.props.mandatoryCategoryIds?.length
? this.props.categories?.filter(c => (showModOptions || c.userCreatable) && this.props.mandatoryCategoryIds?.includes(c.categoryId))
: this.props.categories?.filter(c => showModOptions || c.userCreatable)
) || [];
if (this.state.draftFieldChosenCategoryId !== undefined) draft.categoryId = this.state.draftFieldChosenCategoryId;
var selectedCategory = categoryOptions.find(c => c.categoryId === draft.categoryId);
if (!selectedCategory) {
selectedCategory = categoryOptions[0];
draft.categoryId = selectedCategory?.categoryId;
}
if (!selectedCategory) return null;
if (this.state.draftFieldAuthorId !== undefined) draft.authorUserId = this.state.draftFieldAuthorId;
if (this.state.draftFieldTitle !== undefined) draft.title = this.state.draftFieldTitle;
if (draft.title === undefined && this.props.type === 'post') draft.title = `New ${selectedCategory.name}`;
if (this.state.draftFieldDescription !== undefined) draft.description = this.state.draftFieldDescription;
if (this.state.draftFieldLinkedFromPostIds !== undefined) draft.linkedFromPostIds = this.state.draftFieldLinkedFromPostIds;
if (this.state.draftFieldCoverImage !== undefined) draft.coverImg = this.state.draftFieldCoverImage;
if (this.state.draftFieldChosenTagIds !== undefined) draft.tagIds = this.state.draftFieldChosenTagIds;
if (draft.tagIds?.length) draft.tagIds = draft.tagIds.filter(tagId => selectedCategory?.tagging.tags.some(t => t.tagId === tagId));
if (this.props.mandatoryTagIds?.length) draft.tagIds = [...(draft.tagIds || []), ...this.props.mandatoryTagIds];
if (this.state.draftFieldChosenStatusId !== undefined) draft.statusId = this.state.draftFieldChosenStatusId;
if (draft.statusId && !selectedCategory.workflow.statuses.some(s => s.statusId === draft.statusId)) draft.statusId = undefined;
if (this.state.draftFieldNotifySubscribers !== undefined) draft.notifySubscribers = !this.state.draftFieldNotifySubscribers ? undefined : {
title: `New ${selectedCategory.name}`,
body: `Check out my new post '${draft.title || selectedCategory.name}'`,
...draft.notifySubscribers,
...(this.state.draftFieldNotifyTitle !== undefined ? {
title: this.state.draftFieldNotifyTitle,
} : {}),
...(this.state.draftFieldNotifyBody !== undefined ? {
body: this.state.draftFieldNotifyBody,
} : {}),
};
// External control update
this.props.externalControlRef?.current?.subscription.notify(draft);
const enableSubmit = !!draft.title && !!draft.categoryId && !this.state.tagSelectHasError;
if (this.props.externalSubmit && this.externalSubmitEnabled !== enableSubmit) {
this.externalSubmitEnabled = enableSubmit;
this.props.externalSubmit(enableSubmit ? () => this.createClickSubmit(draft) : undefined);
}
if (this.props.type !== 'post') {
return this.renderRegularAndLarge(draft, categoryOptions, selectedCategory, enableSubmit);
} else {
return this.renderPost(draft, categoryOptions, selectedCategory, enableSubmit);
}
}
renderRegularAndLarge(draft: Partial<Admin.IdeaDraftAdmin>, categoryOptions: Client.Category[], selectedCategory?: Client.Category, enableSubmit?: boolean) {
const editCategory = this.renderEditCategory(draft, categoryOptions, selectedCategory, { className: this.props.classes.createFormField });
const editStatus = this.renderEditStatus(draft, selectedCategory);
const editUser = this.renderEditUser(draft, { className: this.props.classes.createFormField });
const editLinks = this.renderEditLinks(draft, { className: this.props.classes.createFormField });
const editNotify = this.renderEditNotify(draft, selectedCategory);
const editNotifyTitle = this.renderEditNotifyTitle(draft, selectedCategory, { className: this.props.classes.createFormField });
const editNotifyBody = this.renderEditNotifyBody(draft, selectedCategory, { className: this.props.classes.createFormField });
const buttonDiscard = this.renderButtonDiscard();
const buttonDraftSave = this.renderButtonSaveDraft(draft);
const buttonSubmit = this.renderButtonSubmit(draft, enableSubmit);
return (
<Grid
container
justify={this.props.type === 'large' ? 'flex-end' : undefined}
alignItems='flex-start'
className={this.props.classes.createFormFields}
>
<Grid item xs={12} className={this.props.classes.createGridItem}>
{this.renderEditTitle(draft, { TextFieldProps: { className: this.props.classes.createFormField } })}
</Grid>
{this.props.type === 'large' && (
<Grid item xs={3} className={this.props.classes.createGridItem} />
)}
<Grid item xs={12} className={this.props.classes.createGridItem}>
{this.renderEditDescription(draft, { RichEditorProps: { className: this.props.classes.createFormField } })}
</Grid>
{!!editCategory && (
<Grid item xs={this.props.type === 'large' ? 6 : 12} className={this.props.classes.createGridItem}>
{editCategory}
</Grid>
)}
{!!editStatus && (
<Grid item xs={this.props.type === 'large' ? 6 : 12} className={this.props.classes.createGridItem}>
<div className={this.props.classes.createFormField}>
{editStatus}
</div>
</Grid>
)}
{this.renderEditTags(draft, selectedCategory, {
wrapper: (children) => (
<Grid item xs={this.props.type === 'large' ? 6 : 12} className={this.props.classes.createGridItem}>
<div className={this.props.classes.createFormField}>
{children}
</div>
</Grid>
)
})}
{!!editLinks && (
<Grid item xs={this.props.type === 'large' ? 6 : 12} className={this.props.classes.createGridItem}>
{editLinks}
</Grid>
)}
{!!editUser && (
<Grid item xs={this.props.type === 'large' ? 6 : 12} className={this.props.classes.createGridItem} justify='flex-end'>
{editUser}
</Grid>
)}
{!!editNotify && (
<Grid item xs={12} className={this.props.classes.createGridItem}>
{editNotify}
</Grid>
)}
{!!editNotifyTitle && (
<Grid item xs={12} className={this.props.classes.createGridItem}>
{editNotifyTitle}
</Grid>
)}
{!!editNotifyBody && (
<Grid item xs={12} className={this.props.classes.createGridItem}>
{editNotifyBody}
</Grid>
)}
{this.props.type === 'large' && (
<Grid item xs={6} className={this.props.classes.createGridItem} />
)}
<Grid item xs={this.props.type === 'large' ? 6 : 12} container justify='flex-end' className={this.props.classes.createGridItem}>
<Grid item>
{this.props.adminControlsDefaultVisibility !== 'none'
&& this.props.server.isModOrAdminLoggedIn()
&& !this.state.adminControlsExpanded && (
<Button
onClick={e => this.setState({ adminControlsExpanded: true })}
>
Admin
</Button>
)}
{buttonDiscard}
{buttonDraftSave}
{buttonSubmit}
</Grid>
</Grid>
</Grid>
);
}
renderPost(
draft: Partial<Admin.IdeaDraftAdmin>,
categoryOptions: Client.Category[],
selectedCategory?: Client.Category,
enableSubmit?: boolean,
) {
const editTitle = (
<PostTitle
variant='page'
title={draft.title || ''}
editable={this.renderEditTitle(draft, {
bare: true,
autoFocusAndSelect: !this.props.draftId, // Only focus on completely fresh forms
})}
/>
);
const editDescription = (
<ClickToEdit
isEditing={!!this.state.postDescriptionEditing}
setIsEditing={isEditing => this.setState({ postDescriptionEditing: isEditing })}
>
{!this.state.postDescriptionEditing
? (draft.description
? (<PostDescription variant='page' description={draft.description} />)
: (<Typography className={this.props.classes.postDescriptionAdd}>Add description</Typography>)
)
: this.renderEditDescription(draft, {
bare: true,
forceOutline: true,
RichEditorProps: {
autoFocusAndSelect: true,
className: this.props.classes.postDescriptionEdit,
onBlur: () => this.setState({ postDescriptionEditing: false })
},
})}
</ClickToEdit>
);
const editCategory = this.renderEditCategory(draft, categoryOptions, selectedCategory, {
SelectionPickerProps: {
forceDropdownIcon: true,
TextFieldComponent: BareTextField,
},
});
const editStatus = this.renderEditStatus(draft, selectedCategory, {
SelectionPickerProps: {
width: 'unset',
forceDropdownIcon: true,
TextFieldComponent: BareTextField,
},
});
const editTags = this.renderEditTags(draft, selectedCategory, {
SelectionPickerProps: {
width: 'unset',
forceDropdownIcon: true,
clearIndicatorNeverHide: true,
limitTags: 3,
TextFieldComponent: BareTextField,
...(!draft.tagIds?.length ? {
placeholder: 'Add tags',
inputMinWidth: 60,
} : {}),
},
});
const editCover = this.renderEditCover(draft, selectedCategory);
const editUser = this.renderEditUser(draft, {
className: this.props.classes.postUser,
SelectionPickerProps: {
width: 'unset',
forceDropdownIcon: true,
TextFieldComponent: BareTextField,
TextFieldProps: {
fullWidth: false,
},
},
});
const editNotify = this.renderEditNotify(draft, selectedCategory);
const editNotifyTitle = this.renderEditNotifyTitle(draft, selectedCategory, ({
autoFocus: false,
autoFocusAndSelect: !this.props.draftId, // Only focus on completely fresh forms
singlelineWrap: true,
} as React.ComponentProps<typeof BareTextField>) as any, BareTextField);
const editNotifyBody = this.renderEditNotifyBody(draft, selectedCategory, ({
singlelineWrap: true,
} as React.ComponentProps<typeof BareTextField>) as any, BareTextField);
const viewLinks = this.renderViewLinks(draft);
const buttonLink = this.renderButtonLink();
const buttonDiscard = this.renderButtonDiscard();
const buttonDraftSave = this.renderButtonSaveDraft(draft);
const buttonSubmit = this.renderButtonSubmit(draft, enableSubmit);
return (
<div className={this.props.classes.postContainer}>
<div className={this.props.classes.postTitleDesc}>
{editUser}
{editCover}
{editTitle}
{editDescription}
</div>
{(!!editCategory || !!editStatus || !!editTags) && (
<div className={this.props.classes.postFooter}>
{editCategory}
{editStatus}
{editTags}
</div>
)}
{viewLinks}
<div className={this.props.classes.postNotify}>
{(!!editNotify || !!buttonLink) && (
<div className={this.props.classes.postNotifyAndLink}>
{editNotify}
<div className={this.props.classes.grow} />
{buttonLink}
</div>
)}
{(editNotifyTitle || editNotifyBody) && (
<OutlinePostContent className={this.props.classes.postNotifyEnvelope}>
<Typography variant='h5' component='div'>{editNotifyTitle}</Typography>
<Typography variant='body1' component='div'>{editNotifyBody}</Typography>
</OutlinePostContent>
)}
</div>
<DialogActions>
{buttonDiscard}
{buttonDraftSave}
{buttonSubmit}
</DialogActions>
</div>
);
}
renderEditTitle(draft: Partial<Admin.IdeaDraftAdmin>, PostEditTitleProps?: Partial<React.ComponentProps<typeof PostEditTitle>>): React.ReactNode {
return (
<PostEditTitle
value={draft.title || ''}
onChange={value => {
this.setState({ draftFieldTitle: value })
if ((draft.title || '') !== value) {
this.searchSimilarDebounced?.(value, draft.categoryId);
}
}}
isSubmitting={this.state.isSubmitting}
{...PostEditTitleProps}
TextFieldProps={{
size: this.props.type === 'large' ? 'medium' : 'small',
...(this.props.labelTitle ? { label: this.props.labelTitle } : {}),
InputProps: {
inputRef: this.props.titleInputRef,
},
...PostEditTitleProps?.TextFieldProps,
}}
/>
);
}
renderEditDescription(draft: Partial<Admin.IdeaDraftAdmin>, PostEditDescriptionProps?: Partial<React.ComponentProps<typeof PostEditDescription>>): React.ReactNode {
return (
<PostEditDescription
server={this.props.server}
postAuthorId={draft.authorUserId}
isSubmitting={this.state.isSubmitting}
value={draft.description || ''}
onChange={value => {
if (draft.description === value
|| (!draft.description && !value)) {
return;
}
this.setState({ draftFieldDescription: value });
}}
{...PostEditDescriptionProps}
RichEditorProps={{
size: this.props.type === 'large' ? 'medium' : 'small',
minInputHeight: this.props.type === 'large' ? 60 : undefined,
...(this.props.labelDescription ? { label: this.props.labelDescription } : {}),
autoFocusAndSelect: false,
...PostEditDescriptionProps?.RichEditorProps,
}}
/>
);
}
renderEditCategory(
draft: Partial<Admin.IdeaDraftAdmin>,
categoryOptions: Client.Category[],
selectedCategory?: Client.Category,
CategorySelectProps?: Partial<React.ComponentProps<typeof CategorySelect>>,
): React.ReactNode | null {
if (categoryOptions.length <= 1) return null;
return (
<CategorySelect
variant='outlined'
size={this.props.type === 'large' ? 'medium' : 'small'}
label='Category'
categoryOptions={categoryOptions}
value={selectedCategory?.categoryId || ''}
onChange={categoryId => {
if (categoryId === draft.categoryId) return;
this.searchSimilarDebounced?.(draft.title, categoryId);
this.setState({ draftFieldChosenCategoryId: categoryId });
}}
errorText={!selectedCategory ? 'Choose a category' : undefined}
disabled={this.state.isSubmitting}
{...CategorySelectProps}
/>
);
}
renderEditStatus(
draft: Partial<Admin.IdeaDraftAdmin>,
selectedCategory?: Client.Category,
StatusSelectProps?: Partial<React.ComponentProps<typeof StatusSelect>>,
): React.ReactNode | null {
if (!this.showModOptions() || !selectedCategory?.workflow.statuses.length) return null;
return (
<StatusSelect
show='all'
workflow={selectedCategory?.workflow}
variant='outlined'
size={this.props.type === 'large' ? 'medium' : 'small'}
disabled={this.state.isSubmitting}
initialStatusId={selectedCategory.workflow.entryStatus}
statusId={draft.statusId}
onChange={(statusId) => this.setState({ draftFieldChosenStatusId: statusId })}
{...StatusSelectProps}
/>
);
}
renderEditTags(
draft: Partial<Admin.IdeaDraftAdmin>,
selectedCategory?: Client.Category,
TagSelectProps?: Partial<React.ComponentProps<typeof TagSelect>>,
): React.ReactNode | null {
if (!selectedCategory?.tagging.tagGroups.length) return null;
return (
<TagSelect
variant='outlined'
size={this.props.type === 'large' ? 'medium' : 'small'}
label='Tags'
category={selectedCategory}
tagIds={draft.tagIds}
isModOrAdminLoggedIn={this.showModOptions()}
onChange={(tagIds, errorStr) => this.setState({
draftFieldChosenTagIds: tagIds,
tagSelectHasError: !!errorStr,
})}
disabled={this.state.isSubmitting}
mandatoryTagIds={this.props.mandatoryTagIds}
{...TagSelectProps}
SelectionPickerProps={{
limitTags: 1,
...TagSelectProps?.SelectionPickerProps,
}}
/>
);
}
renderEditCover(
draft: Partial<Admin.IdeaDraftAdmin>,
selectedCategory?: Client.Category,
) {
if (!this.showModOptions() || !selectedCategory?.useCover) return null;
return (
<PostCover
coverImg={draft.coverImg}
editable={img => (
<PostCoverEdit
server={this.props.server}
content={img}
onUploaded={coverUrl => this.setState({ draftFieldCoverImage: coverUrl })}
/>
)}
/>
);
}
renderEditUser(
draft: Partial<Admin.IdeaDraftAdmin>,
UserSelectionProps?: Partial<React.ComponentProps<typeof UserSelection>>,
): React.ReactNode | null {
if (!this.showModOptions()) return null;
return (
<UserSelection
variant='outlined'
size={this.props.type === 'large' ? 'medium' : 'small'}
server={this.props.server}
label='As user'
errorMsg='Select author'
width='100%'
disabled={this.state.isSubmitting}
suppressInitialOnChange
initialUserId={draft.authorUserId}
onChange={selectedUserLabel => this.setState({ draftFieldAuthorId: selectedUserLabel?.value })}
allowCreate
{...UserSelectionProps}
/>
);
}
renderEditNotify(
draft: Partial<Admin.IdeaDraftAdmin>,
selectedCategory?: Client.Category,
FormControlLabelProps?: Partial<React.ComponentProps<typeof FormControlLabel>>,
SwitchProps?: Partial<React.ComponentProps<typeof Switch>>,
): React.ReactNode | null {
if (!this.showModOptions()
|| !selectedCategory?.subscription) return null;
return (
<FormControlLabel
disabled={this.state.isSubmitting}
control={(
<Switch
checked={!!draft.notifySubscribers}
onChange={(e, checked) => this.setState({
draftFieldNotifySubscribers: !draft.notifySubscribers,
draftFieldNotifyTitle: undefined,
draftFieldNotifyBody: undefined,
})}
color='primary'
{...SwitchProps}
/>
)}
label='Notify all subscribers'
{...FormControlLabelProps}
/>
);
}
renderEditNotifyTitle(
draft: Partial<Admin.IdeaDraftAdmin>,
selectedCategory?: Client.Category,
TextFieldProps?: Partial<React.ComponentProps<typeof TextField>>,
TextFieldComponent?: React.ElementType<React.ComponentProps<typeof TextField>>,
): React.ReactNode {
if (!this.showModOptions()
|| !selectedCategory?.subscription
|| !draft.notifySubscribers) return null;
const TextFieldCmpt = TextFieldComponent || TextField;
return (
<TextFieldCmpt
variant='outlined'
size={this.props.type === 'large' ? 'medium' : 'small'}
disabled={this.state.isSubmitting}
label='Notification Title'
value={draft.notifySubscribers.title || ''}
onChange={e => this.setState({ draftFieldNotifyTitle: e.target.value })}
autoFocus
{...TextFieldProps}
inputProps={{
maxLength: PostTitleMaxLength,
...TextFieldProps?.inputProps,
}}
/>
);
}
renderEditNotifyBody(
draft: Partial<Admin.IdeaDraftAdmin>,
selectedCategory?: Client.Category,
TextFieldProps?: Partial<React.ComponentProps<typeof TextField>>,
TextFieldComponent?: React.ElementType<React.ComponentProps<typeof TextField>>,
): React.ReactNode {
if (!this.showModOptions()
|| !selectedCategory?.subscription
|| !draft.notifySubscribers) return null;
const TextFieldCmpt = TextFieldComponent || TextField;
return (
<TextFieldCmpt
variant='outlined'
size={this.props.type === 'large' ? 'medium' : 'small'}
disabled={this.state.isSubmitting}
label='Notification Body'
multiline
value={draft.notifySubscribers.body || ''}
onChange={e => this.setState({ draftFieldNotifyBody: e.target.value })}
{...TextFieldProps}
inputProps={{
maxLength: PostTitleMaxLength,
...TextFieldProps?.inputProps,
}}
/>
);
}
renderEditLinks(
draft: Partial<Admin.IdeaDraftAdmin>,
PostSelectionProps?: Partial<React.ComponentProps<typeof PostSelection>>,
): React.ReactNode | null {
if (!this.showModOptions()) return null;
return (
<PostSelection
server={this.props.server}
variant='outlined'
size={this.props.type === 'large' ? 'medium' : 'small'}
disabled={this.state.isSubmitting}
label='Link to'
isMulti
initialPostIds={draft.linkedFromPostIds}
onChange={postIds => this.setState({ draftFieldLinkedFromPostIds: postIds })}
{...PostSelectionProps}
/>
);
}
renderViewLinks(
draft: Partial<Admin.IdeaDraftAdmin>,
): React.ReactNode | null {
if (!draft.linkedFromPostIds?.length) return null;
return (
<ConnectedPostsContainer
className={this.props.classes.postLinksFrom}
type='link'
direction='from'
hasMultiple={draft.linkedFromPostIds.length > 1}
>
{draft.linkedFromPostIds.map(linkedFromPostId => (
<ConnectedPostById
server={this.props.server}
postId={linkedFromPostId}
containerPost={draft}
type='link'
direction='from'
onDisconnect={() => this.setState({
draftFieldLinkedFromPostIds: (this.state.draftFieldLinkedFromPostIds || [])
.filter(id => id !== linkedFromPostId),
})}
PostProps={{
expandable: false,
}}
/>
))}
</ConnectedPostsContainer>
);
}
renderButtonLink(): React.ReactNode | null {
return (
<>
<Provider store={ServerAdmin.get().getStore()}>
<TourAnchor anchorId='post-create-form-link-to-task'>
{(next, isActive, anchorRef) => (
<MyButton
buttonRef={anchorRef}
buttonVariant='post'
disabled={this.state.isSubmitting}
Icon={LinkAltIcon}
onClick={e => {
this.setState({ connectDialogOpen: true });
next();
}}
>
Link
</MyButton>
)}
</TourAnchor>
</Provider>
<PostConnectDialog
onlyAllowLinkFrom
server={this.props.server}
open={!!this.state.connectDialogOpen}
onClose={() => this.setState({ connectDialogOpen: false })}
onSubmit={(selectedPostId, action, directionReversed) => this.setState({
connectDialogOpen: false,
draftFieldLinkedFromPostIds: [...(new Set([...(this.state.draftFieldLinkedFromPostIds || []), selectedPostId]))],
})}
defaultSearch={this.props.defaultConnectSearch}
/>
</>
);
}
renderButtonDiscard(
SubmitButtonProps?: Partial<React.ComponentProps<typeof SubmitButton>>,
): React.ReactNode | null {
if (!this.props.onDiscarded) return null;
return (
<>
<Button
variant='text'
color='inherit'
className={classNames(!!this.props.draftId && this.props.classes.buttonDiscardRed)}
disabled={this.state.isSubmitting}
onClick={e => {
if (!this.props.draftId) {
// If not a draft, discard without prompt
this.discard();
} else {
this.setState({ discardDraftDialogOpen: true });
}
}}
{...SubmitButtonProps}
>
{!!this.props.draftId ? 'Discard' : 'Cancel'}
</Button>
<Dialog
open={!!this.state.discardDraftDialogOpen}
onClose={() => this.setState({ discardDraftDialogOpen: false })}
>
<DialogTitle>Delete draft</DialogTitle>
<DialogContent>
<DialogContentText>Are you sure you want to permanently delete this draft?</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ discardDraftDialogOpen: false })}
>Cancel</Button>
<SubmitButton
variant='text'
color='inherit'
className={this.props.classes.buttonDiscardRed}
isSubmitting={this.state.isSubmitting}
onClick={e => {
this.discard(this.props.draftId);
this.setState({ discardDraftDialogOpen: false });
}}
>
Discard
</SubmitButton>
</DialogActions>
</Dialog>
</>
);
}
renderButtonSaveDraft(
draft: Partial<Admin.IdeaDraftAdmin>,
SubmitButtonProps?: Partial<React.ComponentProps<typeof SubmitButton>>,
): React.ReactNode | null {
if (!this.props.onDraftCreated) return null;
const hasAnyChanges = Object.keys(this.state)
.some(stateKey => stateKey.startsWith('draftField') && this.state[stateKey] !== undefined);
return (
<Provider store={ServerAdmin.get().getStore()}>
<TourAnchor anchorId='post-create-form-save-draft'>
{(next, isActive, anchorRef) => (
<SubmitButton
buttonRef={anchorRef}
variant='text'
disabled={!hasAnyChanges}
isSubmitting={this.state.isSubmitting}
onClick={e => {
this.draftSave(draft);
next();
}}
{...SubmitButtonProps}
>
Save draft
</SubmitButton>
)}
</TourAnchor>
</Provider >
);
}
renderButtonSubmit(
draft: Partial<Admin.IdeaDraftAdmin>,
enableSubmit?: boolean,
SubmitButtonProps?: Partial<React.ComponentProps<typeof SubmitButton>>,
): React.ReactNode | null {
if (!!this.props.externalSubmit) return null;
return (
<Provider store={ServerAdmin.get().getStore()}>
<TourAnchor anchorId='post-create-form-submit-btn' zIndex={zb => zb.modal + 1}>
{(next, isActive, anchorRef) => (
<SubmitButton
buttonRef={anchorRef}
color='primary'
variant='contained'
disableElevation
isSubmitting={this.state.isSubmitting}
disabled={!enableSubmit}
onClick={e => {
enableSubmit && this.createClickSubmit(draft);
next();
}}
{...SubmitButtonProps}
>
{!draft.authorUserId && this.props.unauthenticatedSubmitButtonTitle || 'Submit'}
</SubmitButton>
)}
</TourAnchor>
</Provider>
);
}
async discard(draftId?: string) {
if (!this.props.onDiscarded) return;
this.setState({ isSubmitting: true });
try {
if (draftId) {
await (await this.props.server.dispatchAdmin()).ideaDraftDeleteAdmin({
projectId: this.props.server.getProjectId(),
draftId,
});
}
this.props.onDiscarded();
} finally {
this.setState({ isSubmitting: false });
}
}
async draftSave(
draft: Partial<Admin.IdeaDraftAdmin>,
) {
if (!this.props.onDraftCreated) return;
this.setState({ isSubmitting: true });
try {
if (!draft.draftId) {
const createdDraft = await (await this.props.server.dispatchAdmin()).ideaDraftCreateAdmin({
projectId: this.props.server.getProjectId(),
ideaCreateAdmin: {
...(draft as Admin.IdeaDraftAdmin),
},
});
this.addCreatedDraftToSearches(createdDraft);
this.props.onDraftCreated(createdDraft);
} else {
await (await this.props.server.dispatchAdmin()).ideaDraftUpdateAdmin({
projectId: this.props.server.getProjectId(),
draftId: draft.draftId,
ideaCreateAdmin: {
...(draft as Admin.IdeaDraftAdmin),
},
});
}
const stateUpdate: Pick<State, keyof State> = {};
Object.keys(this.state).forEach(stateKey => {
if (!stateKey.startsWith('draftField')) return;
stateUpdate[stateKey] = undefined;
});
this.setState(stateUpdate);
} finally {
this.setState({ isSubmitting: false });
}
}
addCreatedDraftToSearches(draft: Admin.IdeaDraftAdmin) {
// Warning, very hacky way of doing this.
// For a long time I've been looking for a way to invalidate/update
// stale searches. This needs a better solution once I have more time.
Object.keys(this.props.server.getStore().getState().drafts.bySearch)
.filter(searchKey => searchKey.includes(draft.categoryId))
.forEach(searchKey => {
this.props.server.getStore().dispatch({
type: 'draftSearchResultAddDraft',
payload: {
searchKey,
draftId: draft.draftId,
},
});
});
}
createClickSubmit(
draft: Partial<Admin.IdeaDraftAdmin>,
): Promise<string> {
if (!!draft.authorUserId) {
return this.createSubmit(draft);
} else {
// open log in page, submit on success
return this.props.logInAndGetUserId().then(userId => this.createSubmit({
...draft,
authorUserId: userId,
}));
}
}
async createSubmit(
draft: Partial<Admin.IdeaDraftAdmin>,
): Promise<string> {
this.setState({ isSubmitting: true });
var idea: Client.Idea | Admin.Idea;
try {
if (this.props.server.isModOrAdminLoggedIn()) {
idea = await (await this.props.server.dispatchAdmin()).ideaCreateAdmin({
projectId: this.props.server.getProjectId(),
deleteDraftId: this.props.draftId,
ideaCreateAdmin: {
authorUserId: draft.authorUserId!,
title: draft.title!,
description: draft.description,
categoryId: draft.categoryId!,
statusId: draft.statusId,
notifySubscribers: draft.notifySubscribers,
tagIds: draft.tagIds || [],
linkedFromPostIds: draft.linkedFromPostIds,
coverImg: draft.coverImg,
},
});
} else {
idea = await (await this.props.server.dispatch()).ideaCreate({
projectId: this.props.server.getProjectId(),
ideaCreate: {
authorUserId: draft.authorUserId!,
title: draft.title!,
description: draft.description,
categoryId: draft.categoryId!,
tagIds: draft.tagIds || [],
},
});
}
} catch (e) {
this.setState({
isSubmitting: false,
});
throw e;
}
this.setState({
draftFieldTitle: undefined,
draftFieldDescription: undefined,
isSubmitting: false,
});
this.props.onCreated?.(idea.ideaId);
return idea.ideaId;
}
showModOptions(): boolean {
return !!this.state.adminControlsExpanded
&& (this.props.adminControlsDefaultVisibility !== 'none'
&& this.props.server.isModOrAdminLoggedIn());
}
}
Example #8
Source File: PostPage.tsx From clearflask with Apache License 2.0 | 4 votes |
class PostPage extends Component<Props & ConnectProps & WithTranslation<'app'> & WithWidthProps & WithMediaQueries<keyof MediaQueries> & RouteComponentProps & WithStyles<typeof styles, true>, State> {
state: State = {};
render() {
if (!this.props.postStatus) {
this.props.server.dispatch({ ssr: true, ssrStatusPassthrough: true, debounce: true }).then(d => d.ideaGet({
projectId: this.props.server.getProjectId(),
ideaId: this.props.postId,
}));
}
if (this.props.post?.mergedToPostId) {
return (
<RedirectIso to={preserveEmbed(`/post/${this.props.post.mergedToPostId}`)} />
);
}
if (this.props.post && this.props.projectName && !this.props.suppressSetTitle) {
setAppTitle(this.props.projectName, this.props.post.title);
}
if (this.props.postStatus === Status.REJECTED) {
if (this.props.projectName && !this.props.suppressSetTitle) {
setAppTitle(this.props.projectName, 'Failed to load');
}
return (<ErrorPage msg='Oops, not found' />);
} else if (this.props.postStatus === Status.FULFILLED && this.props.post === undefined) {
if (this.props.projectName && !this.props.suppressSetTitle) {
setAppTitle(this.props.projectName, 'Not found');
}
return (<ErrorPage msg='Oops, not found' />);
}
var subscribeToMe;
if (this.props.category?.subscription?.hellobar && this.props.category) {
const isSubscribed = this.props.loggedInUser?.categorySubscriptions?.includes(this.props.category.categoryId);
subscribeToMe = (
<>
{this.props.category.subscription.hellobar.message && (
<Typography>{this.props.t(this.props.category.subscription.hellobar.message as any)}</Typography>
)}
<SubmitButton
className={this.props.classes.subscribeButton}
isSubmitting={this.state.isSubmitting}
onClick={async () => {
if (!this.props.loggedInUser) {
this.setState({ logInOpen: true });
return;
}
this.setState({ isSubmitting: true });
try {
const dispatcher = await this.props.server.dispatch();
await dispatcher.categorySubscribe({
projectId: this.props.server.getProjectId(),
categoryId: this.props.category!.categoryId,
subscribe: !isSubscribed,
});
} finally {
this.setState({ isSubmitting: false });
}
}}
color='primary'
>
{this.props.t(this.props.category.subscription.hellobar.button || 'follow' as any)}
</SubmitButton>
<LogIn
actionTitle={this.props.t(this.props.category.subscription.hellobar.title as any)}
server={this.props.server}
open={this.state.logInOpen}
onClose={() => this.setState({ logInOpen: false })}
onLoggedInAndClose={async () => {
this.setState({ logInOpen: false });
const dispatcher = await this.props.server.dispatch();
await dispatcher.categorySubscribe({
projectId: this.props.server.getProjectId(),
categoryId: this.props.category!.categoryId,
subscribe: !isSubscribed,
});
}}
/>
</>
);
subscribeToMe = !!this.props.category.subscription.hellobar.title ? (
<DividerCorner
suppressDivider
className={this.props.classes.subscribe}
innerClassName={this.props.classes.subscribeInner}
title={(
<div className={this.props.classes.subscribeTitle}>
<NotifyIcon fontSize='inherit' />
{this.props.t(this.props.category.subscription.hellobar.title as any)}
</div>
)}
>
{subscribeToMe}
</DividerCorner>
) : (
<div
className={classNames(this.props.classes.subscribe, this.props.classes.subscribeInner)}
>
{subscribeToMe}
</div>
);
subscribeToMe = (
<Collapse mountOnEnter in={!isSubscribed}>
{subscribeToMe}
</Collapse>
);
}
const subscribeToMeShowInPanel = !!subscribeToMe && this.props.mediaQueries.spaceForOnePanel;
const similar = (SimilarEnabled && !this.props.suppressSimilar && this.props.post && (
subscribeToMeShowInPanel ? this.props.mediaQueries.spaceForTwoPanels : this.props.mediaQueries.spaceForOnePanel
)) && (
<div className={this.props.classes.similar}>
<PanelPost
direction={Direction.Vertical}
PostProps={this.props.PostProps}
widthExpand
margins={0}
panel={{
hideIfEmpty: true,
title: 'Similar',
search: {
similarToIdeaId: this.props.postId,
filterCategoryIds: [this.props.post.categoryId],
limit: 5,
},
display: {
titleTruncateLines: 1,
descriptionTruncateLines: 2,
responseTruncateLines: 0,
showCommentCount: false,
showCategoryName: false,
showCreated: false,
showAuthor: false,
showStatus: false,
showTags: false,
showVoting: false,
showVotingCount: false,
showFunding: false,
showExpression: false,
},
}}
server={this.props.server}
/>
</div>
);
const post = (
<Post
className={this.props.classes.post}
key='post'
server={this.props.server}
idea={this.props.post}
variant='page'
contentBeforeComments={!subscribeToMeShowInPanel && subscribeToMe}
{...this.props.PostProps}
/>
);
return (
<div className={this.props.classes.container}>
<div className={this.props.classes.panel}>
{similar}
</div>
{post}
<div className={this.props.classes.panel}>
{subscribeToMeShowInPanel && subscribeToMe}
</div>
</div>
);
}
}
Example #9
Source File: Layout.tsx From clearflask with Apache License 2.0 | 4 votes |
class Layout extends Component<Props & WithMediaQueries<any> & WithStyles<typeof styles, true> & WithWidthProps, State> {
readonly editor: ConfigEditor.Editor = new ConfigEditor.EditorImpl();
constructor(props) {
super(props);
this.state = {
mobileMenuOpen: false,
};
}
shouldComponentUpdate = customShouldComponentUpdate({
nested: new Set(['sections', 'mediaQueries']),
presence: new Set(['previewShowNot']),
});
renderHeader(layoutState: LayoutState, header: Section['header']): Header | undefined {
if (!header) return undefined;
if (typeof header === 'function') {
return header(layoutState);
} else {
return header;
}
}
renderHeaderContent(header?: Header, breakAction: BreakAction = 'show'): React.ReactNode | null {
if (!header && breakAction !== 'drawer') return null;
const HeaderActionIcon = header?.action?.icon;
const headerAction = !header?.action ? undefined : (
<TourAnchor {...header.action.tourAnchorProps}>
{(next, isActive, anchorRef) => (
<Button
ref={anchorRef}
className={this.props.classes.headerAction}
disableElevation
color='primary'
onClick={() => {
header.action?.onClick();
next();
}}
>
{header.action?.label}
{!!HeaderActionIcon && (
<>
<HeaderActionIcon fontSize='inherit' color='inherit' />
</>
)}
</Button>
)}
</TourAnchor>
);
const HeaderIcon = header?.title?.icon;
return (
<>
{header?.left}
{!!header?.title && (
<Typography variant='h4' component='h1' className={this.props.classes.headerTitle}>
{HeaderIcon && (
<>
<HeaderIcon fontSize='inherit' color='primary' />
</>
)}
{header.title.title}
{header.title.help && (
<>
<HelpPopper description={header.title.help} />
</>
)}
</Typography>
)}
<div className={this.props.classes.grow} />
{header?.middle && (
<>
{header.middle}
<div className={this.props.classes.grow} />
</>
)}
{headerAction}
{breakAction === 'drawer' && (
<IconButton
color='inherit'
aria-label=''
onClick={this.handlePreviewClose.bind(this)}
className={this.props.classes.previewCloseButton}
>
<CloseIcon />
</IconButton>
)}
{header?.right}
</>
);
}
renderContent(layoutState: LayoutState, content: SectionContent): React.ReactNode | null {
if (!content) {
return null;
} else if (typeof content === 'function') {
return content(layoutState) || null;
} else {
return content;
}
}
renderStackedSections(layoutState: LayoutState, section: Section, breakAction: BreakAction = 'show', stackedSections: Section[] = []): React.ReactNode | null {
if (!stackedSections.length) return this.renderSection(layoutState, section, breakAction);
const sections = [section, ...stackedSections]
.sort((l, r) => (l.breakAction === 'stack' ? l.stackLevel || -1 : 0) - (r.breakAction === 'stack' ? r.stackLevel || -1 : 0));
const contents = sections
.map((section, index, arr) => this.renderSection(layoutState, section, (index === (arr.length - 1)) ? breakAction : 'stack'))
.filter(notEmpty);
if (contents.length === 0) return null;
if (contents.length === 1) return contents[0];
const breakWidth = sections.reduce<number | undefined>((val, section) => section.size?.breakWidth ? Math.max(section.size.breakWidth, (val || 0)) : val, undefined);
return (
<div key={section.name} className={classNames(
this.props.classes.flexVertical,
this.props.classes.stackedSections,
)} style={{
flexGrow: sections.reduce((val, section) => Math.max(section.size?.flexGrow || 0, val), 0),
flexBasis: breakWidth || 'content',
minWidth: breakWidth,
width: sections.find(section => section.size?.width !== undefined)?.size?.width,
maxWidth: sections.find(section => section.size?.maxWidth !== undefined)?.size?.maxWidth,
}}>
{contents}
</div>
);
}
renderSection(layoutState: LayoutState, section: Section, breakAction: BreakAction = 'show'): React.ReactNode | null {
var content = this.renderContent(layoutState, section.content);
if (!content) return null;
if (section.size?.scroll) {
content = (
<div className={classNames(
!!section.size?.scroll ? this.props.classes.scroll : this.props.classes.noscroll,
!!section.size?.scroll && this.props.classes[`scroll-${section.size.scroll}`],
)}>
{content}
</div>
);
}
const isOverflow = breakAction !== 'show' && breakAction !== 'stack';
const header = this.renderHeader(layoutState, section.header);
const headerContent = this.renderHeaderContent(header, breakAction)
const barTop = this.renderContent(layoutState, section.barTop);
const barBottom = this.renderContent(layoutState, section.barBottom);
return (
<div key={section.name} className={classNames(
this.props.classes.section,
this.props.classes.flexVertical,
!isOverflow && layoutState.enableBoxLayout && (!section.noPaper ? this.props.classes.boxPaper : this.props.classes.boxNoPaper),
!isOverflow && layoutState.enableBoxLayout && (section.collapseLeft ? this.props.classes.collapseLeft : this.props.classes.boxLeft),
!isOverflow && layoutState.enableBoxLayout && (section.collapseRight ? this.props.classes.collapseRight : this.props.classes.boxRight),
!isOverflow && layoutState.enableBoxLayout && ((section.collapseTopBottom || section.collapseTop) ? this.props.classes.collapseTop : this.props.classes.boxTop),
!isOverflow && layoutState.enableBoxLayout && breakAction !== 'stack' && ((section.collapseTopBottom || section.collapseBottom) ? this.props.classes.collapseBottom : this.props.classes.boxBottom),
)} style={{
flexGrow: section.size?.flexGrow || 0,
flexBasis: section.size?.breakWidth || 'content',
minWidth: section.size?.breakWidth,
width: section.size?.width,
maxWidth: section.size?.maxWidth,
...(header ? {
marginTop: (header.height || HEADER_HEIGHT) + 1,
} : {})
}}>
<div className={classNames(
this.props.classes.shadows,
!section.noPaper && this.props.classes.hideShadows,
)}>
{!!headerContent && (
<div className={classNames(
this.props.classes.sectionHeader,
!layoutState.enableBoxLayout && this.props.classes.sectionHeaderNobox,
)} style={{
transform: `translateY(-${HEADER_HEIGHT + 1}px)`,
height: header?.height || HEADER_HEIGHT,
}}>
{headerContent}
</div>
)}
{!!barTop && (
<>
<div className={this.props.classes.bar}>
{barTop}
</div>
<Divider />
</>
)}
{content}
{!!barBottom && (
<>
<Divider />
<div className={this.props.classes.bar}>
{barBottom}
</div>
</>
)}
</div>
</div>
);
}
render() {
const stackedSectionsForName: { [name: string]: Section[] } = {};
const breakActionForName: { [name: string]: BreakAction } = {};
this.props.sections.forEach(section => {
const breakAction = (section.breakAlways
? section.breakAction
: (this.props.mediaQueries[section.name] === false && section.breakAction))
|| 'show';
breakActionForName[section.name] = breakAction;
if (breakAction === 'stack' && section.breakAction === 'stack') {
stackedSectionsForName[section.stackWithSectionName] = stackedSectionsForName[section.stackWithSectionName] || [];
stackedSectionsForName[section.stackWithSectionName].push(section);
}
});
const layoutState: LayoutState = {
isShown: name => breakActionForName[name] || 'show',
enableBoxLayout: this.props.mediaQueries.enableBoxLayout,
};
const sectionPreview = this.props.sections.find(s => s.breakAction === 'drawer');
const contentPreview = !sectionPreview || layoutState.isShown(sectionPreview.name) !== 'drawer'
? null : this.renderStackedSections(layoutState, sectionPreview, 'drawer', stackedSectionsForName[sectionPreview.name]);
const sectionMenu = this.props.sections.find(s => s.breakAction === 'menu');
const contentMenu = !sectionMenu || layoutState.isShown(sectionMenu.name) !== 'menu'
? null : this.renderStackedSections(layoutState, sectionMenu, 'menu', stackedSectionsForName[sectionMenu.name]);
const contents: React.ReactNode[] = [];
this.props.sections.forEach(section => {
const breakAction = breakActionForName[section.name];
if (breakAction !== 'show') return;
const content = this.renderStackedSections(layoutState, section, breakAction, stackedSectionsForName[section.name]);
if (!content) return;
contents.push(content);
});
return (
<div>
{!!this.props.toolbarShow && (
<Portal>
<AppBar elevation={0} color='default' className={this.props.classes.appBar}>
<Toolbar>
{!!contentMenu && (
<IconButton
color="inherit"
aria-label="Open drawer"
onClick={this.handleDrawerToggle.bind(this)}
className={this.props.classes.menuButton}
>
<MenuIcon />
</IconButton>
)}
{this.props.toolbarLeft}
<div className={this.props.classes.grow} />
{this.props.toolbarRight}
</Toolbar>
<Divider />
</AppBar>
</Portal>
)}
{!!contentMenu && (
<Drawer
variant='temporary'
open={this.state.mobileMenuOpen}
onClose={this.handleDrawerToggle.bind(this)}
classes={{
paper: classNames(this.props.classes.menuPaper),
}}
style={{
width: sectionMenu?.size?.maxWidth || sectionMenu?.size?.width || sectionMenu?.size?.breakWidth || '100%',
}}
ModalProps={{
keepMounted: true,
}}
>
{!!this.props.toolbarShow && (<div className={this.props.classes.toolbarSpacer} />)}
{contentMenu}
</Drawer>
)}
{!!contentPreview && (
<Drawer
variant='temporary'
SlideProps={{ mountOnEnter: true }}
anchor='right'
open={!!this.props.previewShow}
onClose={this.handlePreviewClose.bind(this)}
classes={{
modal: this.props.classes.previewMobileModal,
paper: this.props.classes.previewMobilePaper,
}}
style={{
width: sectionPreview?.size?.maxWidth || sectionPreview?.size?.width || sectionPreview?.size?.breakWidth || '100%',
}}
>
{!!this.props.toolbarShow && (<div className={this.props.classes.toolbarSpacer} />)}
{contentPreview}
</Drawer>
)}
<div className={classNames(this.props.classes.page, this.props.classes.flexVertical)}>
{!!this.props.toolbarShow && (<div className={this.props.classes.toolbarSpacer} />)}
<div className={classNames(
this.props.classes.sections,
layoutState.enableBoxLayout ? this.props.classes.sectionsBox : this.props.classes.sectionsNobox,
this.props.classes.grow,
this.props.classes.flexHorizontal,
)}>
{contents}
</div>
</div>
</div>
);
}
handleDrawerToggle() {
this.setState({ mobileMenuOpen: !this.state.mobileMenuOpen });
};
handlePreviewClose() {
this.props.previewShowNot();
};
}
Example #10
Source File: Dashboard.tsx From clearflask with Apache License 2.0 | 4 votes |
export class Dashboard extends Component<Props & ConnectProps & WithTranslation<'site'> & RouteComponentProps & WithStyles<typeof styles, true> & WithWidthProps & WithSnackbarProps, State> {
static stripePromise: Promise<Stripe | null> | undefined;
unsubscribes: { [projectId: string]: () => void } = {};
forcePathListener: ((forcePath: string) => void) | undefined;
lastConfigVer?: string;
similarPostWasClicked?: {
originalPostId: string;
similarPostId: string;
};
draggingPostIdSubscription = new Subscription<string | undefined>(undefined);
readonly feedbackListRef = createMutableRef<PanelPostNavigator>();
readonly changelogPostDraftExternalControlRef = createMutableRef<ExternalControl>();
state: State = {};
constructor(props) {
super(props);
Dashboard.getStripePromise();
}
componentDidMount() {
if (this.props.accountStatus === undefined) {
this.bind();
} else if (!this.props.configsStatus) {
ServerAdmin.get().dispatchAdmin().then(d => d.configGetAllAndUserBindAllAdmin());
}
}
static getStripePromise(): Promise<Stripe | null> {
if (!Dashboard.stripePromise) {
try {
loadStripe.setLoadParameters({ advancedFraudSignals: false });
} catch (e) {
// Frontend reloads in-place and causes stripe to be loaded multiple times
if (detectEnv() !== Environment.DEVELOPMENT_FRONTEND) {
throw e;
}
};
Dashboard.stripePromise = loadStripe(isProd()
? 'pk_live_6HJ7aPzGuVyPwTX5ngwAw0Gh'
: 'pk_test_51Dfi5vAl0n0hFnHPXRnnJdMKRKF6MMOWLQBwLl1ifwPZysg1wJNtYcumjgO8oPHlqITK2dXWlbwLEsPYas6jpUkY00Ryy3AtGP');
}
return Dashboard.stripePromise;
}
async bind() {
try {
if (detectEnv() === Environment.DEVELOPMENT_FRONTEND) {
const mocker = await import(/* webpackChunkName: "mocker" */'../mocker')
await mocker.mock();
}
const dispatcher = await ServerAdmin.get().dispatchAdmin();
const result = await dispatcher.accountBindAdmin({ accountBindAdmin: {} });
if (result.account) {
await dispatcher.configGetAllAndUserBindAllAdmin();
}
this.forceUpdate();
} catch (er) {
this.forceUpdate();
throw er;
}
}
componentWillUnmount() {
Object.values(this.unsubscribes).forEach(unsubscribe => unsubscribe());
}
render() {
if (this.props.accountStatus === Status.FULFILLED && !this.props.account) {
return (<Redirect to={{
pathname: '/login',
state: { [ADMIN_LOGIN_REDIRECT_TO]: this.props.location.pathname }
}} />);
} else if (this.props.configsStatus !== Status.FULFILLED || !this.props.bindByProjectId || !this.props.account) {
return (<LoadingPage />);
}
const activePath = this.props.match.params['path'] || '';
if (activePath === BillingPaymentActionRedirectPath) {
return (
<BillingPaymentActionRedirect />
);
}
const projects = Object.keys(this.props.bindByProjectId)
.filter(projectId => !projectId.startsWith('demo-'))
.map(projectId => ServerAdmin.get().getOrCreateProject(projectId));
projects.forEach(project => {
if (!this.unsubscribes[project.projectId]) {
this.unsubscribes[project.projectId] = project.subscribeToUnsavedChanges(() => {
this.forceUpdate();
});
}
});
const projectOptions: Label[] = projects.map(p => ({
label: getProjectName(p.editor.getConfig()),
filterString: p.editor.getConfig().name,
value: p.projectId
}));
var selectedLabel: Label | undefined = this.state.selectedProjectId ? projectOptions.find(o => o.value === this.state.selectedProjectId) : undefined;
if (!selectedLabel) {
const params = new URL(windowIso.location.href).searchParams;
const selectedProjectIdFromParams = params.get(SELECTED_PROJECT_ID_PARAM_NAME);
if (selectedProjectIdFromParams) {
selectedLabel = projectOptions.find(o => o.value === selectedProjectIdFromParams);
}
}
if (!selectedLabel) {
const selectedProjectIdFromLocalStorage = localStorage.getItem(SELECTED_PROJECT_ID_LOCALSTORAGE_KEY);
if (selectedProjectIdFromLocalStorage) {
selectedLabel = projectOptions.find(o => o.value === selectedProjectIdFromLocalStorage);
}
}
if (activePath === 'create') {
selectedLabel = undefined;
} else if (!selectedLabel && projects.length > 0) {
selectedLabel = { label: getProjectName(projects[0].editor.getConfig()), value: projects[0].projectId };
}
const activeProjectId: string | undefined = selectedLabel?.value;
const activeProject = projects.find(p => p.projectId === activeProjectId);
if (activeProject && this.lastConfigVer !== activeProject.configVersion) {
this.lastConfigVer = activeProject.configVersion;
const templater = Templater.get(activeProject.editor);
const feedbackPromise = templater.feedbackGet();
const roadmapPromise = templater.roadmapGet();
const landingPromise = templater.landingGet();
const changelogPromise = templater.changelogGet();
feedbackPromise
.then(i => this.setState({ feedback: i || null }))
.catch(e => this.setState({ feedback: undefined }));
roadmapPromise
.then(i => this.setState({ roadmap: i || null }))
.catch(e => this.setState({ roadmap: undefined }));
landingPromise
.then(i => this.setState({ landing: i || null }))
.catch(e => this.setState({ landing: undefined }));
changelogPromise
.then(i => this.setState({ changelog: i || null }))
.catch(e => this.setState({ changelog: undefined }));
const allPromise = Promise.all([feedbackPromise, roadmapPromise, changelogPromise]);
allPromise
.then(all => {
const hasUncategorizedCategories = !activeProject.editor.getConfig().content.categories.every(category =>
category.categoryId === all[0]?.categoryAndIndex.category.categoryId
|| category.categoryId === all[1]?.categoryAndIndex.category.categoryId
|| category.categoryId === all[2]?.categoryAndIndex.category.categoryId
);
this.setState({ hasUncategorizedCategories });
})
.catch(e => this.setState({ hasUncategorizedCategories: true }));
}
const context: DashboardPageContext = {
activeProject,
sections: [],
};
switch (activePath) {
case '':
setTitle('Home - Dashboard');
context.showProjectLink = true;
if (!activeProject) {
context.showCreateProjectWarning = true;
break;
}
context.sections.push({
name: 'main',
size: { flexGrow: 1, scroll: Orientation.Vertical },
collapseTopBottom: true,
noPaper: true,
content: (
<div className={this.props.classes.homeContainer}>
<Provider key={activeProject.projectId} store={activeProject.server.getStore()}>
<DashboardHome
server={activeProject.server}
editor={activeProject.editor}
feedback={this.state.feedback || undefined}
roadmap={this.state.roadmap || undefined}
changelog={this.state.changelog || undefined}
/>
</Provider>
<Provider store={ServerAdmin.get().getStore()}>
<TourChecklist />
</Provider>
{/* <Hidden smDown>
<Provider key={activeProject.projectId} store={activeProject.server.getStore()}>
<TemplateWrapper<[RoadmapInstance | undefined, ChangelogInstance | undefined]>
key='roadmap-public'
type='dialog'
editor={activeProject.editor}
mapper={templater => Promise.all([templater.roadmapGet(), templater.changelogGet()])}
renderResolved={(templater, [roadmap, changelog]) => !!roadmap?.pageAndIndex?.page.board && (
<Provider key={activeProject.projectId} store={activeProject.server.getStore()}>
<BoardContainer
title={roadmap.pageAndIndex.page.board.title}
panels={roadmap.pageAndIndex.page.board.panels.map((panel, panelIndex) => (
<BoardPanel
server={activeProject.server}
panel={panel}
PanelPostProps={{
onClickPost: postId => this.pageClicked('post', [postId]),
onUserClick: userId => this.pageClicked('user', [userId]),
selectable: 'highlight',
selected: this.state.roadmapPreview?.type === 'post' ? this.state.roadmapPreview.id : undefined,
}}
/>
))}
/>
</Provider>
)}
/>
</Provider>
</Hidden> */}
</div>
),
});
break;
case 'explore':
this.renderExplore(context);
break;
case 'feedback':
this.renderFeedback(context);
break;
case 'roadmap':
this.renderRoadmap(context);
break;
case 'changelog':
this.renderChangelog(context);
break;
case 'users':
this.renderUsers(context);
break;
case 'billing':
context.sections.push({
name: 'main',
content: (<RedirectIso to='/dashboard/settings/account/billing' />)
});
break;
case 'account':
context.sections.push({
name: 'main',
content: (<RedirectIso to='/dashboard/settings/account/profile' />)
});
break;
case 'welcome':
case 'create':
context.showProjectLink = true;
const isOnboarding = activePath === 'welcome'
&& this.props.account?.basePlanId !== TeammatePlanId;
if (isOnboarding) {
context.isOnboarding = true;
setTitle('Welcome');
} else {
setTitle('Create a project - Dashboard');
}
context.sections.push({
name: 'main',
noPaper: true, collapseTopBottom: true, collapseLeft: true, collapseRight: true,
size: { flexGrow: 1, breakWidth: 300, scroll: Orientation.Vertical },
content: (
<CreatePage
isOnboarding={isOnboarding}
projectCreated={(projectId) => {
this.setSelectedProjectId(projectId);
}}
/>
),
});
break;
case 'settings':
this.renderSettings(context);
break;
case 'contact':
context.sections.push({
name: 'main',
noPaper: true,
collapseTopBottom: true, collapseLeft: true, collapseRight: true,
size: { flexGrow: 1, breakWidth: 300, scroll: Orientation.Vertical },
content: (<ContactPage />)
});
break;
case 'e':
context.sections.push({
name: 'main',
noPaper: true,
collapseTopBottom: true, collapseLeft: true, collapseRight: true,
size: { flexGrow: 1, breakWidth: 300, scroll: Orientation.Vertical },
content: (<LandingEmbedFeedbackPage browserPathPrefix='/dashboard/e' embed />)
});
break;
default:
setTitle('Page not found');
context.showWarning = 'Oops, cannot find page';
break;
}
if (context.showCreateProjectWarning || context.showWarning) {
context.sections = [{
name: 'main',
content: (<ErrorPage msg={context.showWarning || 'Oops, you have to create a project first'} />),
}];
context.showCreateProjectWarning && this.props.history.replace('/dashboard/welcome');
}
const activeProjectConf = activeProject?.server.getStore().getState().conf.conf;
const projectLink = (!!activeProjectConf && !!context.showProjectLink)
? getProjectLink(activeProjectConf) : undefined;
var content = (
<>
{this.props.account && (
<SubscriptionStatusNotifier account={this.props.account} />
)}
<Layout
toolbarShow={!context.isOnboarding}
toolbarLeft={(
<div className={this.props.classes.toolbarLeft}>
<Tabs
className={this.props.classes.tabs}
variant='standard'
scrollButtons='off'
classes={{
indicator: this.props.classes.tabsIndicator,
flexContainer: this.props.classes.tabsFlexContainer,
}}
value={activePath || 'home'}
indicatorColor="primary"
textColor="primary"
>
<Tab
className={this.props.classes.tab}
component={Link}
to='/dashboard'
value='home'
disableRipple
label={(<Logo suppressMargins />)}
classes={{
root: this.props.classes.tabRoot,
}}
/>
{!!this.state.hasUncategorizedCategories && (
<Tab
className={this.props.classes.tab}
component={Link}
to='/dashboard/explore'
value='explore'
disableRipple
label={this.props.t('explore')}
classes={{
root: this.props.classes.tabRoot,
}}
/>
)}
{this.state.feedback !== null && (
<Tab
className={this.props.classes.tab}
component={Link}
to='/dashboard/feedback'
value='feedback'
disableRipple
label={this.props.t('feedback')}
classes={{
root: this.props.classes.tabRoot,
}}
/>
)}
{this.state.roadmap !== null && (
<Tab
className={this.props.classes.tab}
component={Link}
to='/dashboard/roadmap'
value='roadmap'
disableRipple
label={this.props.t('roadmap')}
classes={{
root: this.props.classes.tabRoot,
}}
/>
)}
{this.state.changelog !== null && (
<Tab
className={this.props.classes.tab}
component={Link}
to='/dashboard/changelog'
value='changelog'
disableRipple
label={this.props.t('announcements')}
classes={{
root: this.props.classes.tabRoot,
}}
/>
)}
<Tab
className={this.props.classes.tab}
component={Link}
to='/dashboard/users'
value='users'
disableRipple
label={this.props.t('users')}
classes={{
root: this.props.classes.tabRoot,
}}
/>
</Tabs>
</div>
)}
toolbarRight={
<>
<LanguageSelect />
<MenuItems
items={[
...(!!projectLink ? [{
type: 'button' as 'button', tourAnchorProps: {
anchorId: 'dashboard-visit-portal', placement: 'bottom' as 'bottom',
}, onClick: () => {
!windowIso.isSsr && windowIso.open(projectLink, '_blank');
tourSetGuideState('visit-project', TourDefinitionGuideState.Completed);
}, title: this.props.t('visit'), icon: VisitIcon
}] : []),
{
type: 'dropdown', title: (!!activeProject && projects.length > 1) ? getProjectName(activeProject.editor.getConfig()) : this.props.account.name,
color: 'primary', items: [
...(projects.map(p => ({
type: 'button' as 'button', onClick: () => this.setSelectedProjectId(p.projectId), title: getProjectName(p.editor.getConfig()),
icon: p.projectId === activeProjectId ? CheckIcon : undefined
}))),
{ type: 'divider' },
{ type: 'button', link: '/dashboard/create', title: this.props.t('add-project'), icon: AddIcon },
{ type: 'button', link: '/dashboard/settings/project/branding', title: this.props.t('settings'), icon: SettingsIcon },
{ type: 'divider' },
// { type: 'button', link: this.openFeedbackUrl('docs'), linkIsExternal: true, title: 'Documentation' },
{ type: 'button', link: '/dashboard/contact', title: this.props.t('contact') },
{ type: 'button', link: '/dashboard/e/feedback', title: this.props.t('give-feedback') },
{ type: 'button', link: '/dashboard/e/roadmap', title: this.props.t('our-roadmap') },
{ type: 'divider' },
{ type: 'button', link: '/dashboard/settings/account/profile', title: this.props.t('account'), icon: AccountIcon },
...(!!this.props.isSuperAdmin && detectEnv() !== Environment.PRODUCTION_SELF_HOST ? [
{ type: 'button' as 'button', link: '/dashboard/settings/super/loginas', title: 'Super Admin', icon: SuperAccountIcon },
] : []),
{
type: 'button', onClick: () => {
ServerAdmin.get().dispatchAdmin().then(d => d.accountLogoutAdmin());
redirectIso('/login', this.props.history);
}, title: this.props.t('sign-out'), icon: LogoutIcon
},
]
}
]}
/>
</>
}
previewShow={!!this.state.previewShowOnPage && this.state.previewShowOnPage === activePath}
previewShowNot={() => {
this.setState({ previewShowOnPage: undefined });
context.previewOnClose?.();
}}
previewForceShowClose={!!context.previewOnClose}
sections={context.sections}
/>
</>
);
content = (
<Elements stripe={Dashboard.getStripePromise()}>
{content}
</Elements>
);
content = (
<DragDropContext
enableDefaultSensors
sensors={[api => {
if (this.state.dragDropSensorApi !== api) {
this.setState({ dragDropSensorApi: api });
}
}]}
onBeforeCapture={(before) => {
if (!activeProject) return;
const srcPost = activeProject.server.getStore().getState().ideas.byId[before.draggableId]?.idea;
if (!srcPost) return;
this.draggingPostIdSubscription.notify(srcPost.ideaId);
}}
onDragEnd={(result, provided) => {
this.draggingPostIdSubscription.notify(undefined);
if (!result.destination || !activeProject) return;
dashboardOnDragEnd(
activeProject,
result.source.droppableId,
result.source.index,
result.draggableId,
result.destination.droppableId,
result.destination.index,
this.state.feedback || undefined,
this.state.roadmap || undefined,
context.onDndHandled,
context.onDndPreHandling);
}}
>
{content}
</DragDropContext>
);
content = (
<ClearFlaskTourProvider
feedback={this.state.feedback || undefined}
roadmap={this.state.roadmap || undefined}
changelog={this.state.changelog || undefined}
>
{content}
</ClearFlaskTourProvider>
);
return content;
}
renderExplore = renderExplore;
renderFeedback = renderFeedback;
renderRoadmap = renderRoadmap;
renderChangelog = renderChangelog;
renderUsers = renderUsers;
renderSettings = renderSettings;
async publishChanges(currentProject: AdminProject): Promise<AdminClient.VersionedConfigAdmin> {
const d = await ServerAdmin.get().dispatchAdmin();
const versionedConfigAdmin = await d.configSetAdmin({
projectId: currentProject.projectId,
versionLast: currentProject.configVersion,
configAdmin: currentProject.editor.getConfig(),
});
currentProject.resetUnsavedChanges(versionedConfigAdmin);
return versionedConfigAdmin;
}
renderPreview(preview: {
project?: AdminProject
stateKey: keyof State,
renderEmpty?: string,
extra?: Partial<Section> | ((previewState: PreviewState | undefined) => Partial<Section>),
createCategoryIds?: string[],
createAllowDrafts?: boolean,
postDraftExternalControlRef?: MutableRef<ExternalControl>;
}): Section | null {
if (!preview.project) {
return preview.renderEmpty ? this.renderPreviewEmpty('No project selected') : null;
}
const previewState = this.state[preview.stateKey] as PreviewState | undefined;
var section;
if (!previewState) {
section = preview.renderEmpty !== undefined ? this.renderPreviewEmpty(preview.renderEmpty) : null;
} else if (previewState.type === 'create-post') {
section = this.renderPreviewPostCreate(preview.stateKey, preview.project, previewState.draftId, preview.createCategoryIds, preview.createAllowDrafts, previewState.defaultStatusId, preview.postDraftExternalControlRef);
} else if (previewState.type === 'post') {
section = this.renderPreviewPost(previewState.id, preview.stateKey, preview.project, previewState.headerTitle, previewState.headerIcon);
} else if (previewState.type === 'create-user') {
section = this.renderPreviewUserCreate(preview.stateKey, preview.project);
} else if (previewState.type === 'user') {
section = this.renderPreviewUser(previewState.id, preview.stateKey, preview.project);
}
if (section && preview.extra) {
section = {
...section,
...(typeof preview.extra === 'function' ? preview.extra(previewState) : preview.extra),
};
}
return section;
}
renderPreviewPost(postId: string, stateKey: keyof State, project: AdminProject, headerTitle?: string, headerIcon?: OverridableComponent<SvgIconTypeMap>): Section {
return {
name: 'preview',
breakAction: 'drawer',
size: PostPreviewSize,
...(headerTitle ? {
header: { title: { title: headerTitle, icon: headerIcon } },
} : {}),
content: (
<Provider key={project.projectId} store={project.server.getStore()}>
<Fade key={postId} in appear>
<div>
<DashboardPost
key={postId}
server={project.server}
postId={postId}
onClickPost={postId => this.pageClicked('post', [postId])}
onUserClick={userId => this.pageClicked('user', [userId])}
onDeleted={() => this.setState({ [stateKey]: undefined } as any)}
/>
</div>
</Fade>
</Provider>
),
};
}
renderPreviewUser(userId: string, stateKey: string, project?: AdminProject): Section {
if (!project) {
return this.renderPreviewEmpty('No project selected');
}
return {
name: 'preview',
breakAction: 'drawer',
size: UserPreviewSize,
content: (
<Provider key={project.projectId} store={project.server.getStore()}>
<Fade key={userId} in appear>
<div>
<UserPage
key={userId}
server={project.server}
userId={userId}
suppressSignOut
onDeleted={() => this.setState({ [stateKey]: undefined } as any)}
/>
</div>
</Fade>
</Provider>
),
};
}
renderPreviewPostCreate(
stateKey: string,
project?: AdminProject,
draftId?: string,
mandatoryCategoryIds?: string[],
allowDrafts?: boolean,
defaultStatusId?: string,
externalControlRef?: MutableRef<ExternalControl>,
): Section {
if (!project) {
return this.renderPreviewEmpty('No project selected');
}
return {
name: 'preview',
breakAction: 'drawer',
size: PostPreviewSize,
content: (
<Provider key={project.projectId} store={project.server.getStore()}>
<Fade key='post-create' in appear>
<div>
<PostCreateForm
key={draftId || 'new'}
server={project.server}
type='post'
mandatoryCategoryIds={mandatoryCategoryIds}
adminControlsDefaultVisibility='expanded'
logInAndGetUserId={() => new Promise<string>(resolve => this.setState({ postCreateOnLoggedIn: resolve }))}
draftId={draftId}
defaultStatusId={defaultStatusId}
defaultConnectSearch={(stateKey === 'changelogPreview' && this.state.roadmap) ? {
filterCategoryIds: [this.state.roadmap.categoryAndIndex.category.categoryId],
filterStatusIds: this.state.roadmap.statusIdCompleted ? [this.state.roadmap.statusIdCompleted] : undefined,
} : undefined}
onCreated={postId => {
this.setState({ [stateKey]: { type: 'post', id: postId } as PreviewState } as any);
}}
onDraftCreated={allowDrafts ? draft => {
this.setState({ [stateKey]: { type: 'create-post', draftId: draft.draftId } as PreviewState } as any);
} : undefined}
onDiscarded={() => {
this.setState({ [stateKey]: undefined } as any);
}}
externalControlRef={externalControlRef}
/>
<LogIn
actionTitle='Get notified of replies'
server={project.server}
open={!!this.state.postCreateOnLoggedIn}
onClose={() => this.setState({ postCreateOnLoggedIn: undefined })}
onLoggedInAndClose={userId => {
if (this.state.postCreateOnLoggedIn) {
this.state.postCreateOnLoggedIn(userId);
this.setState({ postCreateOnLoggedIn: undefined });
}
}}
/>
</div>
</Fade>
</Provider>
),
};
}
renderPreviewUserCreate(stateKey: keyof State, project?: AdminProject): Section {
if (!project) {
return this.renderPreviewEmpty('No project selected');
}
return {
name: 'preview',
breakAction: 'drawer',
size: UserPreviewSize,
content: (
<Provider key={project.projectId} store={project.server.getStore()}>
<Fade key='user-create' in appear>
<div>
<UserPage
server={project.server}
suppressSignOut
onDeleted={() => this.setState({ [stateKey]: undefined } as any)}
/>
</div>
</Fade>
</Provider>
),
};
}
renderPreviewChangesDemo(project?: AdminProject, showCodeForProject?: boolean): Section {
if (!project) {
return this.renderPreviewEmpty('No project selected');
}
return {
name: 'preview',
breakAction: 'drawer',
size: ProjectPreviewSize,
content: (
<>
<div style={{ display: 'flex', alignItems: 'center', margin: 4, }}>
<IconButton onClick={() => this.setState({
settingsPreviewChanges: !!showCodeForProject ? 'live' : 'code',
})}>
{!!showCodeForProject ? <CodeIcon /> : <VisibilityIcon />}
</IconButton>
{!!showCodeForProject ? 'Previewing configuration details' : 'Previewing changes with live data'}
</div>
<Divider />
{!showCodeForProject ? (
<DemoApp
key={project.configVersion}
server={project.server}
settings={{ suppressSetTitle: true }}
forcePathSubscribe={listener => this.forcePathListener = listener}
/>
) : (
<ConfigView
key={project.projectId}
server={project.server}
editor={project.editor}
/>
)}
</>
),
};
}
renderPreviewEmpty(msg: string, size?: LayoutSize): Section {
return {
name: 'preview',
breakAction: 'drawer',
size: size || { breakWidth: 350, flexGrow: 100, maxWidth: 1024 },
content: (
<Fade key={msg} in appear>
<div className={this.props.classes.previewEmptyMessage}>
<Typography component='div' variant='h5'>
{msg}
</Typography>
<EmptyIcon
fontSize='inherit'
className={this.props.classes.previewEmptyIcon}
/>
</div>
</Fade>
),
};
}
openFeedbackUrl(page?: string) {
var url = `${windowIso.location.protocol}//product.${windowIso.location.host}/${page || ''}`;
if (this.props.account) {
url += `?${SSO_TOKEN_PARAM_NAME}=${this.props.account.cfJwt}`;
}
return url;
}
openPost(postId?: string, redirectPage?: string) {
this.pageClicked('post', [postId || '', redirectPage || '']);
}
pageClicked(path: string, subPath: ConfigEditor.Path = []): void {
if (path === 'post') {
// For post, expected parameters for subPath are:
// 0: postId or null for create
// 1: page to redirect to
const postId = !!subPath[0] ? (subPath[0] + '') : undefined;
const redirectPath = subPath[1];
const redirect = !!redirectPath ? () => this.props.history.push('/dashboard/' + redirectPath) : undefined;
const activePath = redirectPath || this.props.match.params['path'] || '';
const preview: State['explorerPreview'] & State['feedbackPreview'] & State['roadmapPreview'] = !!postId
? { type: 'post', id: postId }
: { type: 'create-post' };
if (activePath === 'feedback') {
this.setState({
// previewShowOnPage: 'feedback', // Always shown
feedbackPreview: preview,
}, redirect);
} else if (activePath === 'explore') {
this.setState({
previewShowOnPage: 'explore',
explorerPreview: preview,
}, redirect);
} else if (activePath === 'roadmap') {
this.setState({
previewShowOnPage: 'roadmap',
roadmapPreview: preview,
}, redirect);
} else if (activePath === 'changelog') {
this.setState({
previewShowOnPage: 'changelog',
changelogPreview: preview,
}, redirect);
} else {
this.setState({
previewShowOnPage: 'explore',
explorerPreview: preview,
}, () => this.props.history.push('/dashboard/explore'));
}
} else if (path === 'user') {
this.setState({
previewShowOnPage: 'users',
usersPreview: !!subPath[0]
? { type: 'user', id: subPath[0] + '' }
: { type: 'create-user' },
}, () => this.props.history.push('/dashboard/users'));
} else {
this.props.history.push(`/dashboard/${[path, ...subPath].join('/')}`);
}
}
showSnackbar(props: ShowSnackbarProps) {
this.props.enqueueSnackbar(props.message, {
key: props.key,
variant: props.variant,
persist: props.persist,
action: !props.actions?.length ? undefined : (key) => (
<>
{props.actions?.map(action => (
<Button
color='inherit'
onClick={() => action.onClick(() => this.props.closeSnackbar(key))}
>{action.title}</Button>
))}
</>
),
});
}
setSelectedProjectId(selectedProjectId: string) {
if (this.state.selectedProjectId === selectedProjectId) return;
localStorage.setItem(SELECTED_PROJECT_ID_LOCALSTORAGE_KEY, selectedProjectId);
this.setState(prevState => ({
...(Object.keys(prevState).reduce((s, key) => ({ ...s, [key]: undefined }), {})),
selectedProjectId,
}));
this.props.history.push('/dashboard');
}
}
Example #11
Source File: BillingPage.tsx From clearflask with Apache License 2.0 | 4 votes |
class BillingPage extends Component<Props & ConnectProps & WithStyles<typeof styles, true> & RouteComponentProps & WithWidthProps, State> {
state: State = {};
refreshBillingAfterPaymentClose?: boolean;
paymentActionMessageListener?: any;
constructor(props) {
super(props);
props.callOnMount?.();
}
componentWillUnmount() {
this.paymentActionMessageListener && !windowIso.isSsr && windowIso.removeEventListener('message', this.paymentActionMessageListener);
}
render() {
if (!this.props.account) {
return 'Need to login to see this page';
}
const status = this.props.accountStatus === Status.FULFILLED ? this.props.accountBillingStatus : this.props.accountStatus;
if (!this.props.accountBilling || status !== Status.FULFILLED) {
return (
<Loader skipFade status={status} />
);
}
var cardNumber, cardExpiry, cardStateIcon;
if (!!this.props.accountBilling?.payment) {
cardNumber = (
<>
<span className={this.props.classes.blurry}>5200 8282 8282 </span>
{this.props.accountBilling.payment.last4}
</>
);
var expiryColor;
if (new Date().getFullYear() % 100 >= this.props.accountBilling.payment.expiryYear % 100) {
if (new Date().getMonth() + 1 === this.props.accountBilling.payment.expiryMonth) {
expiryColor = this.props.theme.palette.warning.main;
} else if (new Date().getMonth() + 1 > this.props.accountBilling.payment.expiryMonth) {
expiryColor = this.props.theme.palette.error.main;
}
}
cardExpiry = (
<span style={expiryColor && { color: expiryColor }}>
{this.props.accountBilling.payment.expiryMonth}
/
{this.props.accountBilling.payment.expiryYear % 100}
</span>
);
} else {
cardNumber = (<span className={this.props.classes.blurry}>5200 8282 8282 8210</span>);
cardExpiry = (<span className={this.props.classes.blurry}>06 / 32</span>);
}
var hasAvailablePlansToSwitch: boolean = (this.props.accountBilling?.availablePlans || [])
.filter(p => p.basePlanId !== this.props.accountBilling?.plan.basePlanId)
.length > 0;
var cardState: 'active' | 'warn' | 'error' = 'active';
var paymentTitle, paymentDesc, showContactSupport, showSetPayment, setPaymentTitle, setPaymentAction, showCancelSubscription, showResumePlan, resumePlanDesc, planTitle, planDesc, showPlanChange, endOfTermChangeToPlanTitle, endOfTermChangeToPlanDesc, switchPlanTitle;
switch (this.props.account.subscriptionStatus) {
case Admin.SubscriptionStatus.Active:
if (this.props.accountBilling?.plan.basePlanId === TeammatePlanId) {
paymentTitle = 'No payment required';
paymentDesc = 'While you only access external projects, payments are made by the project owner. No payment is required from you at this time.';
cardState = 'active';
showSetPayment = false;
showCancelSubscription = false;
planTitle = 'You are not on a plan';
planDesc = 'While you only access external projects, you are not required to be on a plan. If you decide to create a project under your account, you will be able to choose a plan and your trial will begin.';
if (hasAvailablePlansToSwitch) {
showPlanChange = true;
switchPlanTitle = 'Choose plan'
}
} else {
paymentTitle = 'Automatic renewal is active';
paymentDesc = 'You will be automatically billed at the next cycle and your plan will be renewed.';
cardState = 'active';
showSetPayment = true;
setPaymentTitle = 'Update payment method';
showCancelSubscription = true;
planTitle = 'Your plan is active';
planDesc = `You have full access to your ${this.props.accountBilling.plan.title} plan.`;
if (hasAvailablePlansToSwitch) {
planDesc += ' If you upgrade your plan, changes will reflect immediately. If you downgrade your plan, changes will take effect at the end of the term.';
showPlanChange = true;
}
}
break;
case Admin.SubscriptionStatus.ActiveTrial:
if (this.props.accountBilling?.payment) {
paymentTitle = 'Automatic renewal is active';
if (this.props.accountBilling?.billingPeriodEnd) {
paymentDesc = (
<>
Your first payment will be automatically billed at the end of the trial period in <TimeAgo date={this.props.accountBilling?.billingPeriodEnd} />.
</>
);
} else {
paymentDesc = `Your first payment will be automatically billed at the end of the trial period.`;
}
cardState = 'active';
showSetPayment = true;
setPaymentTitle = 'Update payment method';
planTitle = 'Your plan is active';
planDesc = `You have full access to your ${this.props.accountBilling.plan.title} plan.`;
if (hasAvailablePlansToSwitch) {
planDesc += ' If you switch plans now, your first payment at the end of your trial will reflect your new plan.';
showPlanChange = true;
}
} else {
paymentTitle = 'Automatic renewal requires a payment method';
paymentDesc = 'To continue using our service beyond the trial period, add a payment method to enable automatic renewal.';
cardState = 'warn';
showSetPayment = true;
setPaymentTitle = 'Add payment method';
planTitle = 'Your plan is active until your trial ends';
if (this.props.accountBilling?.billingPeriodEnd) {
planDesc = (
<>
You have full access to your {this.props.accountBilling.plan.title} plan until your trial expires in <TimeAgo date={this.props.accountBilling?.billingPeriodEnd} />. Add a payment method to continue using our service beyond the trial period.
</>
);
} else {
planDesc = `You have full access to your ${this.props.accountBilling.plan.title} plan until your trial expires. Add a payment method to continue using our service beyond the trial period.`;
}
if (hasAvailablePlansToSwitch) {
showPlanChange = true;
}
}
break;
case Admin.SubscriptionStatus.ActivePaymentRetry:
paymentTitle = 'Automatic renewal is having issues with your payment method';
paymentDesc = 'We are having issues charging your payment method. We will retry your payment method again soon and we may block your service if unsuccessful.';
cardState = 'error';
showSetPayment = true;
if (this.props.accountBilling?.payment) {
setPaymentTitle = 'Update payment method';
} else {
setPaymentTitle = 'Add payment method';
}
showCancelSubscription = true;
planTitle = 'Your plan is active';
planDesc = `You have full access to your ${this.props.accountBilling.plan.title} plan; however, there is an issue with your payment. Please resolve it before you can change your plan.`;
break;
case Admin.SubscriptionStatus.ActiveNoRenewal:
paymentTitle = 'Automatic renewal is inactive';
paymentDesc = 'Resume automatic renewal to continue using our service beyond the next billing cycle.';
cardState = 'warn';
showSetPayment = true;
setPaymentTitle = 'Resume with new payment method';
setPaymentAction = 'Add and resume subscription';
showResumePlan = true;
resumePlanDesc = 'Your subscription will no longer be cancelled. You will be automatically billed for our service at the next billing cycle.';
if (this.props.accountBilling?.billingPeriodEnd) {
planTitle = (
<>
Your plan is active until <TimeAgo date={this.props.accountBilling?.billingPeriodEnd} />
</>
);
} else {
planTitle = 'Your plan is active until the end of the billing cycle';
}
planDesc = `You have full access to your ${this.props.accountBilling.plan.title} plan until it cancels. Please resume your payments to continue using our service beyond next billing cycle.`;
break;
case Admin.SubscriptionStatus.Limited:
paymentTitle = 'Automatic renewal is active';
paymentDesc = 'You will be automatically billed at the next cycle and your plan will be renewed.';
cardState = 'active';
showSetPayment = true;
setPaymentTitle = 'Update payment method';
showCancelSubscription = true;
planTitle = 'Your plan is limited';
planDesc = `You have limited access to your ${this.props.accountBilling.plan.title} plan due to going over your plan limits. Please resolve all issues to continue using our service.`;
if (hasAvailablePlansToSwitch) {
planDesc += ' If you upgrade your plan, changes will reflect immediately. If you downgrade your plan, changes will take effect at the end of the term.';
showPlanChange = true;
}
break;
case Admin.SubscriptionStatus.NoPaymentMethod:
paymentTitle = 'Automatic renewal is inactive';
paymentDesc = 'Your trial has expired. To continue using our service, add a payment method to enable automatic renewal.';
cardState = 'error';
showSetPayment = true;
setPaymentTitle = 'Add payment method';
planTitle = 'Your trial plan has expired';
planDesc = `To continue using your ${this.props.accountBilling.plan.title} plan, please add a payment method.`;
break;
case Admin.SubscriptionStatus.Blocked:
paymentTitle = 'Payments are blocked';
paymentDesc = 'Contact support to reinstate your account.';
showContactSupport = true;
cardState = 'error';
planTitle = 'Your plan is inactive';
planDesc = `You have limited access to your ${this.props.accountBilling.plan.title} plan due to a payment issue. Please resolve all issues to continue using our service.`;
break;
case Admin.SubscriptionStatus.Cancelled:
paymentTitle = 'Automatic renewal is inactive';
paymentDesc = 'Resume automatic renewal to continue using our service.';
cardState = 'error';
showSetPayment = true;
setPaymentTitle = 'Update payment method';
if (this.props.accountBilling?.payment) {
showResumePlan = true;
resumePlanDesc = 'Your subscription will no longer be cancelled. You will be automatically billed for our service starting now.';
}
planTitle = 'Your plan is cancelled';
planDesc = `You have limited access to your ${this.props.accountBilling.plan.title} plan since you cancelled your subscription. Please resume payment to continue using our service.`;
break;
}
if (this.props.accountBilling?.endOfTermChangeToPlan) {
endOfTermChangeToPlanTitle = `Pending plan change to ${this.props.accountBilling.endOfTermChangeToPlan.title}`;
endOfTermChangeToPlanDesc = `Your requested change of plans to ${this.props.accountBilling.endOfTermChangeToPlan.title} plan will take effect at the end of the term.`;
}
switch (cardState) {
case 'active':
cardStateIcon = (<ActiveIcon color='primary' />);
break;
case 'warn':
cardStateIcon = (<WarnIcon style={{ color: this.props.theme.palette.warning.main }} />);
break;
case 'error':
cardStateIcon = (<ErrorIcon color='error' />);
break;
}
const creditCard = (
<TourAnchor anchorId='settings-credit-card' placement='bottom'>
<CreditCard
className={this.props.classes.creditCard}
brand={cardStateIcon}
numberInput={cardNumber}
expiryInput={cardExpiry}
cvcInput={(<span className={this.props.classes.blurry}>642</span>)}
/>
</TourAnchor>
);
const paymentStripeAction: PaymentStripeAction | undefined = this.props.accountBilling?.paymentActionRequired?.actionType === 'stripe-next-action'
? this.props.accountBilling?.paymentActionRequired as PaymentStripeAction : undefined;
const paymentActionOnClose = () => {
this.setState({
paymentActionOpen: undefined,
paymentActionUrl: undefined,
paymentActionMessage: undefined,
paymentActionMessageSeverity: undefined,
});
if (this.refreshBillingAfterPaymentClose) {
ServerAdmin.get().dispatchAdmin().then(d => d.accountBillingAdmin({
refreshPayments: true,
}));
}
};
const paymentAction = paymentStripeAction ? (
<>
<Message
className={this.props.classes.paymentActionMessage}
message='One of your payments requires additional information'
severity='error'
action={(
<SubmitButton
isSubmitting={!!this.state.paymentActionOpen && !this.state.paymentActionUrl && !this.state.paymentActionMessage}
onClick={() => {
this.setState({ paymentActionOpen: true });
this.loadActionIframe(paymentStripeAction);
}}
>Open</SubmitButton>
)}
/>
<Dialog
open={!!this.state.paymentActionOpen}
onClose={paymentActionOnClose}
>
{this.state.paymentActionMessage ? (
<>
<DialogContent>
<Message
message={this.state.paymentActionMessage}
severity={this.state.paymentActionMessageSeverity || 'info'}
/>
</DialogContent>
<DialogActions>
<Button onClick={paymentActionOnClose}>Dismiss</Button>
</DialogActions>
</>
) : (this.state.paymentActionUrl ? (
<iframe
title='Complete outstanding payment action'
width={this.getFrameActionWidth()}
height={400}
src={this.state.paymentActionUrl}
/>
) : (
<div style={{
minWidth: this.getFrameActionWidth(),
minHeight: 400,
}}>
<LoadingPage />
</div>
))}
</Dialog>
</>
) : undefined;
const hasPayable = (this.props.accountBilling?.accountPayable || 0) > 0;
const hasReceivable = (this.props.accountBilling?.accountReceivable || 0) > 0;
const payment = (
<Section
title='Payment'
preview={(
<div className={this.props.classes.creditCardContainer}>
{creditCard}
<Box display='grid' gridTemplateAreas='"payTtl payAmt" "rcvTtl rcvAmt"' alignItems='center' gridGap='10px 10px'>
{hasPayable && (
<>
<Box gridArea='payTtl'><Typography component='div'>Credits:</Typography></Box>
<Box gridArea='payAmt' display='flex'>
<Typography component='div' variant='h6' color='textSecondary' style={{ alignSelf: 'flex-start' }}>{'$'}</Typography>
<Typography component='div' variant='h4' color={hasPayable ? 'primary' : undefined}>
{this.props.accountBilling?.accountPayable || 0}
</Typography>
</Box>
</>
)}
{(hasReceivable || !hasPayable) && (
<>
<Box gridArea='rcvTtl'><Typography component='div'>Overdue:</Typography></Box>
<Box gridArea='rcvAmt' display='flex'>
<Typography component='div' variant='h6' color='textSecondary' style={{ alignSelf: 'flex-start' }}>{'$'}</Typography>
<Typography component='div' variant='h4' color={hasReceivable ? 'error' : undefined}>
{this.props.accountBilling?.accountReceivable || 0}
</Typography>
</Box>
</>
)}
</Box>
</div>
)}
content={(
<div className={this.props.classes.actionContainer}>
<p><Typography variant='h6' color='textPrimary' component='div'>{paymentTitle}</Typography></p>
<Typography color='textSecondary'>{paymentDesc}</Typography>
<div className={this.props.classes.sectionButtons}>
{showContactSupport && (
<Button
disabled={this.state.isSubmitting || this.state.showAddPayment}
component={Link}
to='/contact/support'
>Contact support</Button>
)}
{showSetPayment && (
<TourAnchor anchorId='settings-add-payment-open' placement='bottom'>
{(next, isActive, anchorRef) => (
<SubmitButton
buttonRef={anchorRef}
isSubmitting={this.state.isSubmitting}
disabled={this.state.showAddPayment}
onClick={() => {
trackingBlock(() => {
ReactGA.event({
category: 'billing',
action: this.props.accountBilling?.payment ? 'click-payment-update-open' : 'click-payment-add-open',
label: this.props.accountBilling?.plan.basePlanId,
});
});
this.setState({ showAddPayment: true });
next();
}}
>
{setPaymentTitle}
</SubmitButton>
)}
</TourAnchor>
)}
{showCancelSubscription && (
<SubmitButton
isSubmitting={this.state.isSubmitting}
disabled={this.state.showCancelSubscription}
style={{ color: this.props.theme.palette.error.main }}
onClick={() => this.setState({ showCancelSubscription: true })}
>
Cancel payments
</SubmitButton>
)}
{showResumePlan && (
<SubmitButton
isSubmitting={this.state.isSubmitting}
disabled={this.state.showResumePlan}
color='primary'
onClick={() => this.setState({ showResumePlan: true })}
>
Resume payments
</SubmitButton>
)}
</div>
{paymentAction}
<Dialog
open={!!this.state.showAddPayment}
onClose={() => this.setState({ showAddPayment: undefined })}
>
<ElementsConsumer>
{({ elements, stripe }) => (
<TourAnchor anchorId='settings-add-payment-popup' placement='top'>
{(next, isActive, anchorRef) => (
<div ref={anchorRef}>
<DialogTitle>{setPaymentTitle || 'Add new payment method'}</DialogTitle>
<DialogContent className={this.props.classes.center}>
<StripeCreditCard onFilledChanged={(isFilled) => this.setState({ stripePaymentFilled: isFilled })} />
<Collapse in={!!this.state.stripePaymentError}>
<Message message={this.state.stripePaymentError} severity='error' />
</Collapse>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ showAddPayment: undefined })}>
Cancel
</Button>
<SubmitButton
isSubmitting={this.state.isSubmitting}
disabled={!this.state.stripePaymentFilled || !elements || !stripe}
color='primary'
onClick={async () => {
const success = await this.onPaymentSubmit(elements!, stripe!);
if (success) {
next();
tourSetGuideState('add-payment', TourDefinitionGuideState.Completed);
}
}}
>{setPaymentAction || 'Add'}</SubmitButton>
</DialogActions>
</div>
)}
</TourAnchor>
)}
</ElementsConsumer>
</Dialog>
<Dialog
open={!!this.state.showCancelSubscription}
onClose={() => this.setState({ showCancelSubscription: undefined })}
>
<DialogTitle>Stop subscription</DialogTitle>
<DialogContent className={this.props.classes.center}>
<DialogContentText>Stop automatic billing of your subscription. Any ongoing subscription will continue to work until it expires.</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ showCancelSubscription: undefined })}>
Cancel
</Button>
<SubmitButton
isSubmitting={this.state.isSubmitting}
style={{ color: this.props.theme.palette.error.main }}
onClick={() => {
this.setState({ isSubmitting: true });
ServerAdmin.get().dispatchAdmin().then(d => d.accountUpdateAdmin({
accountUpdateAdmin: {
cancelEndOfTerm: true,
},
}).then(() => d.accountBillingAdmin({})))
.then(() => this.setState({ isSubmitting: false, showCancelSubscription: undefined }))
.catch(er => this.setState({ isSubmitting: false }));
}}
>Stop subscription</SubmitButton>
</DialogActions>
</Dialog>
<Dialog
open={!!this.state.showResumePlan}
onClose={() => this.setState({ showResumePlan: undefined })}
>
<DialogTitle>Resume subscription</DialogTitle>
<DialogContent className={this.props.classes.center}>
<DialogContentText>{resumePlanDesc}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ showResumePlan: undefined })}>
Cancel
</Button>
<SubmitButton
isSubmitting={this.state.isSubmitting}
color='primary'
onClick={() => {
this.setState({ isSubmitting: true });
ServerAdmin.get().dispatchAdmin().then(d => d.accountUpdateAdmin({
accountUpdateAdmin: {
resume: true,
},
}).then(() => d.accountBillingAdmin({})))
.then(() => this.setState({ isSubmitting: false, showResumePlan: undefined }))
.catch(er => this.setState({ isSubmitting: false }));
}}
>Resume subscription</SubmitButton>
</DialogActions>
</Dialog>
</div>
)}
/>
);
const nextInvoicesCursor = this.state.invoices === undefined
? this.props.accountBilling?.invoices.cursor
: this.state.invoicesCursor;
const invoicesItems = [
...(this.props.accountBilling?.invoices.results || []),
...(this.state.invoices || []),
];
const invoices = invoicesItems.length <= 0 ? undefined : (
<Section
title='Invoices'
content={(
<>
<Table>
<TableHead>
<TableRow>
<TableCell key='due'>Due</TableCell>
<TableCell key='status'>Status</TableCell>
<TableCell key='amount'>Amount</TableCell>
<TableCell key='desc'>Description</TableCell>
<TableCell key='invoiceLink'>Invoice</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoicesItems.map((invoiceItem, index) => (
<TableRow key={index}>
<TableCell key='due'><Typography>{new Date(invoiceItem.date).toLocaleDateString()}</Typography></TableCell>
<TableCell key='status' align='center'><Typography>{invoiceItem.status}</Typography></TableCell>
<TableCell key='amount' align='right'><Typography>{invoiceItem.amount}</Typography></TableCell>
<TableCell key='desc'><Typography>{invoiceItem.description}</Typography></TableCell>
<TableCell key='invoiceLink'>
<Button onClick={() => this.onInvoiceClick(invoiceItem.invoiceId)}>View</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{nextInvoicesCursor && (
<Button
style={{ margin: 'auto', display: 'block' }}
onClick={() => ServerAdmin.get().dispatchAdmin()
.then(d => d.invoicesSearchAdmin({ cursor: nextInvoicesCursor }))
.then(results => this.setState({
invoices: [
...(this.state.invoices || []),
...results.results,
],
invoicesCursor: results.cursor,
}))}
>
Show more
</Button>
)}
</>
)}
/>
);
const plan = (
<Section
title='Plan'
preview={(
<div className={this.props.classes.planContainer}>
<TourAnchor anchorId='settings-billing-plan' placement='bottom' disablePortal>
<PricingPlan
selected
className={this.props.classes.plan}
plan={this.props.accountBilling.plan}
/>
</TourAnchor>
{(this.props.accountBilling?.trackedUsers !== undefined) && (
<Box display='grid' gridTemplateAreas='"mauLbl mauAmt"' alignItems='baseline' gridGap='10px 10px'>
<Box gridArea='mauLbl'><Typography component='div'>Tracked users:</Typography></Box>
<Box gridArea='mauAmt' display='flex'>
<Typography component='div' variant='h5'>
{this.props.accountBilling.trackedUsers}
</Typography>
</Box>
</Box>
)}
{(this.props.accountBilling?.postCount !== undefined) && (
<Box display='grid' gridTemplateAreas='"postCountLbl postCountAmt"' alignItems='baseline' gridGap='10px 10px'>
<Box gridArea='postCountLbl'><Typography component='div'>Post count:</Typography></Box>
<Box gridArea='postCountAmt' display='flex'>
<Typography component='div' variant='h5' color={
this.props.account.basePlanId === 'starter-unlimited'
&& this.props.accountBilling.postCount > StarterMaxPosts
? 'error' : undefined}>
{this.props.accountBilling.postCount}
</Typography>
</Box>
</Box>
)}
</div>
)}
content={(
<div className={this.props.classes.actionContainer}>
<p><Typography variant='h6' component='div' color='textPrimary'>{planTitle}</Typography></p>
<Typography color='textSecondary'>{planDesc}</Typography>
{(endOfTermChangeToPlanTitle || endOfTermChangeToPlanDesc) && (
<>
<p><Typography variant='h6' component='div' color='textPrimary' className={this.props.classes.sectionSpacing}>{endOfTermChangeToPlanTitle}</Typography></p>
<Typography color='textSecondary'>{endOfTermChangeToPlanDesc}</Typography>
</>
)}
{showPlanChange && (
<div className={this.props.classes.sectionButtons}>
<Button
disabled={this.state.isSubmitting || this.state.showPlanChange}
onClick={() => {
trackingBlock(() => {
ReactGA.event({
category: 'billing',
action: 'click-plan-switch-open',
label: this.props.accountBilling?.plan.basePlanId,
});
});
this.setState({ showPlanChange: true });
}}
>
{switchPlanTitle || 'Switch plan'}
</Button>
</div>
)}
{showPlanChange && (
<div className={this.props.classes.sectionButtons}>
<Button
disabled={this.state.isSubmitting || this.state.showPlanChange}
onClick={() => this.props.history.push('/coupon')}
>
Redeem coupon
</Button>
</div>
)}
{this.props.isSuperAdmin && (
<>
<Dialog
open={!!this.state.showFlatYearlyChange}
onClose={() => this.setState({ showFlatYearlyChange: undefined })}
scroll='body'
maxWidth='md'
>
<DialogTitle>Switch to yearly plan</DialogTitle>
<DialogContent>
<TextField
variant='outlined'
type='number'
label='Yearly flat price'
value={this.state.flatYearlyPrice !== undefined ? this.state.flatYearlyPrice : ''}
onChange={e => this.setState({ flatYearlyPrice: parseInt(e.target.value) >= 0 ? parseInt(e.target.value) : undefined })}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ showFlatYearlyChange: undefined })}
>Cancel</Button>
<SubmitButton
isSubmitting={this.state.isSubmitting}
disabled={this.state.flatYearlyPrice === undefined}
color='primary'
onClick={() => {
this.setState({ isSubmitting: true });
ServerAdmin.get().dispatchAdmin().then(d => d.accountUpdateSuperAdmin({
accountUpdateSuperAdmin: {
changeToFlatPlanWithYearlyPrice: this.state.flatYearlyPrice || 0,
},
}).then(() => d.accountBillingAdmin({})))
.then(() => this.setState({ isSubmitting: false, showFlatYearlyChange: undefined }))
.catch(er => this.setState({ isSubmitting: false }));
}}
>Change</SubmitButton>
</DialogActions>
</Dialog>
<div className={this.props.classes.sectionButtons}>
<Button
disabled={this.state.isSubmitting}
onClick={() => this.setState({ showFlatYearlyChange: true })}
>Flatten</Button>
</div>
</>
)}
{this.props.isSuperAdmin && (
<>
<Dialog
open={!!this.state.showAddonsChange}
onClose={() => this.setState({ showAddonsChange: undefined })}
scroll='body'
maxWidth='md'
>
<DialogTitle>Manage addons</DialogTitle>
<DialogContent className={this.props.classes.addonsContainer}>
<TextField
label='Extra projects'
variant='outlined'
type='number'
value={this.state.extraProjects !== undefined ? this.state.extraProjects : (this.props.account.addons?.[AddonExtraProject] || 0)}
onChange={e => this.setState({ extraProjects: parseInt(e.target.value) >= 0 ? parseInt(e.target.value) : undefined })}
/>
<FormControlLabel
control={(
<Switch
checked={this.state.whitelabel !== undefined ? this.state.whitelabel : !!this.props.account.addons?.[AddonWhitelabel]}
onChange={(e, checked) => this.setState({ whitelabel: !!checked })}
color='default'
/>
)}
label={(<FormHelperText>Whitelabel</FormHelperText>)}
/>
<FormControlLabel
control={(
<Switch
checked={this.state.privateProjects !== undefined ? this.state.privateProjects : !!this.props.account.addons?.[AddonPrivateProjects]}
onChange={(e, checked) => this.setState({ privateProjects: !!checked })}
color='default'
/>
)}
label={(<FormHelperText>Private projects</FormHelperText>)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ showAddonsChange: undefined })}
>Cancel</Button>
<SubmitButton
isSubmitting={this.state.isSubmitting}
disabled={this.state.whitelabel === undefined
&& this.state.privateProjects === undefined
&& this.state.extraProjects === undefined}
color='primary'
onClick={() => {
if (this.state.whitelabel === undefined
&& this.state.privateProjects === undefined
&& this.state.extraProjects === undefined) return;
this.setState({ isSubmitting: true });
ServerAdmin.get().dispatchAdmin().then(d => d.accountUpdateSuperAdmin({
accountUpdateSuperAdmin: {
addons: {
...(this.state.whitelabel === undefined ? {} : {
[AddonWhitelabel]: this.state.whitelabel ? 'true' : ''
}),
...(this.state.privateProjects === undefined ? {} : {
[AddonPrivateProjects]: this.state.privateProjects ? 'true' : ''
}),
...(this.state.extraProjects === undefined ? {} : {
[AddonExtraProject]: `${this.state.extraProjects}`
}),
},
},
}).then(() => d.accountBillingAdmin({})))
.then(() => this.setState({ isSubmitting: false, showAddonsChange: undefined }))
.catch(er => this.setState({ isSubmitting: false }));
}}
>Change</SubmitButton>
</DialogActions>
</Dialog>
<div className={this.props.classes.sectionButtons}>
<Button
disabled={this.state.isSubmitting}
onClick={() => this.setState({ showAddonsChange: true })}
>Addons</Button>
</div>
</>
)}
{this.props.isSuperAdmin && (
<>
<Dialog
open={!!this.state.showCreditAdjustment}
onClose={() => this.setState({ showCreditAdjustment: undefined })}
scroll='body'
maxWidth='md'
>
<DialogTitle>Credit adjustment</DialogTitle>
<DialogContent className={this.props.classes.addonsContainer}>
<TextField
label='Amount'
variant='outlined'
type='number'
value={this.state.creditAmount || 0}
onChange={e => this.setState({ creditAmount: parseInt(e.target.value) })}
/>
<TextField
label='Description'
variant='outlined'
value={this.state.creditDescription || ''}
onChange={e => this.setState({ creditDescription: e.target.value })}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ showCreditAdjustment: undefined })}
>Cancel</Button>
<SubmitButton
isSubmitting={this.state.isSubmitting}
disabled={!this.props.account
|| !this.state.creditAmount
|| !this.state.creditDescription}
color='primary'
onClick={() => {
if (!this.props.account
|| !this.state.creditAmount
|| !this.state.creditDescription) return;
this.setState({ isSubmitting: true });
ServerAdmin.get().dispatchAdmin().then(d => d.accountCreditAdjustmentSuperAdmin({
accountCreditAdjustment: {
accountId: this.props.account!.accountId,
amount: this.state.creditAmount!,
description: this.state.creditDescription!,
},
}).then(() => d.accountBillingAdmin({})))
.then(() => this.setState({ isSubmitting: false, showCreditAdjustment: undefined, creditAmount: undefined, creditDescription: undefined }))
.catch(er => this.setState({ isSubmitting: false }));
}}
>Change</SubmitButton>
</DialogActions>
</Dialog>
<div className={this.props.classes.sectionButtons}>
<Button
disabled={this.state.isSubmitting}
onClick={() => this.setState({ showCreditAdjustment: true })}
>Credit</Button>
</div>
</>
)}
<BillingChangePlanDialog
open={!!this.state.showPlanChange}
onClose={() => this.setState({ showPlanChange: undefined })}
onSubmit={basePlanId => {
trackingBlock(() => {
ReactGA.event({
category: 'billing',
action: 'click-plan-switch-submit',
label: basePlanId,
});
});
this.setState({ isSubmitting: true });
ServerAdmin.get().dispatchAdmin().then(d => d.accountUpdateAdmin({
accountUpdateAdmin: {
basePlanId,
},
}).then(() => d.accountBillingAdmin({})))
.then(() => this.setState({ isSubmitting: false, showPlanChange: undefined }))
.catch(er => this.setState({ isSubmitting: false }));
}}
isSubmitting={!!this.state.isSubmitting}
/>
</div>
)}
/>
);
return (
<ProjectSettingsBase title='Billing'>
{plan}
{payment}
{invoices}
</ProjectSettingsBase>
);
}
onInvoiceClick(invoiceId: string) {
!windowIso.isSsr && windowIso.open(`${windowIso.location.origin}/invoice/${invoiceId}`, '_blank')
}
async onPaymentSubmit(elements: StripeElements, stripe: Stripe): Promise<boolean> {
trackingBlock(() => {
ReactGA.event({
category: 'billing',
action: this.props.accountBilling?.payment ? 'click-payment-update-submit' : 'click-payment-add-submit',
label: this.props.accountBilling?.plan.basePlanId,
value: this.props.accountBilling?.plan.pricing?.basePrice,
});
});
this.setState({ isSubmitting: true, stripePaymentError: undefined });
const cardNumberElement = elements.getElement(CardNumberElement);
if (cardNumberElement === null) {
this.setState({
stripePaymentError: 'Payment processor not initialized yet',
isSubmitting: false,
});
return false;
}
const tokenResult = await stripe.createToken(cardNumberElement);
if (!tokenResult.token) {
this.setState({
stripePaymentError: tokenResult.error
? `${tokenResult.error.message} (${tokenResult.error.code || tokenResult.error.decline_code || tokenResult.error.type})`
: 'Payment processor failed for unknown reason',
isSubmitting: false,
});
return false;
}
const dispatcher = await ServerAdmin.get().dispatchAdmin();
try {
await dispatcher.accountUpdateAdmin({
accountUpdateAdmin: {
paymentToken: {
type: 'killbill-stripe',
token: tokenResult.token.id,
},
renewAutomatically: true,
},
});
} catch (er) {
this.setState({
isSubmitting: false,
stripePaymentError: 'Failed to add payment',
});
return false;
}
try {
await dispatcher.accountBillingAdmin({});
} catch (er) {
this.setState({
isSubmitting: false,
stripePaymentError: 'Failed to add payment',
});
return false;
}
this.setState({ isSubmitting: false, showAddPayment: undefined });
return true;
}
getFrameActionWidth(): number {
// https://stripe.com/docs/payments/3d-secure#render-iframe
if (!this.props.width) return 250;
switch (this.props.width) {
case 'xs':
return 250;
case 'sm':
return 390;
case 'md':
case 'lg':
case 'xl':
default:
return 600;
}
}
async loadActionIframe(paymentStripeAction: PaymentStripeAction) {
var stripe: Stripe | null = null;
try {
stripe = await this.props.stripePromise;
} catch (e) {
// Handle below
}
if (!stripe) {
this.refreshBillingAfterPaymentClose = true;
this.setState({
paymentActionMessage: 'Payment gateway unavailable',
paymentActionMessageSeverity: 'error',
})
return;
}
var result: { paymentIntent?: PaymentIntent, error?: StripeError } | undefined;
try {
result = await stripe.confirmCardPayment(
paymentStripeAction.actionData.paymentIntentClientSecret,
{ return_url: `${windowIso.location.protocol}//${windowIso.location.host}/dashboard/${BillingPaymentActionRedirectPath}` },
{ handleActions: false });
} catch (e) {
this.refreshBillingAfterPaymentClose = true;
this.setState({
paymentActionMessage: 'Failed to load payment gateway',
paymentActionMessageSeverity: 'error',
})
return;
}
if (result.error || !result.paymentIntent) {
this.refreshBillingAfterPaymentClose = true;
this.setState({
paymentActionMessage: result.error?.message || 'Unknown payment failure',
paymentActionMessageSeverity: 'error',
})
return;
}
if (result.paymentIntent.status === 'succeeded') {
this.refreshBillingAfterPaymentClose = true;
this.setState({
paymentActionMessage: 'No action necessary',
paymentActionMessageSeverity: 'success',
})
return;
}
if (result.paymentIntent.status === 'canceled') {
this.refreshBillingAfterPaymentClose = true;
this.setState({
paymentActionMessage: 'Payment already canceled',
paymentActionMessageSeverity: 'error',
})
return;
}
if (result.paymentIntent.status !== 'requires_action'
|| !result.paymentIntent.next_action?.redirect_to_url?.url) {
this.refreshBillingAfterPaymentClose = true;
this.setState({
paymentActionMessage: `Unexpected payment status: ${result.paymentIntent.status}`,
paymentActionMessageSeverity: 'error',
})
return;
}
// Setup iframe message listener
this.paymentActionMessageListener = (ev: MessageEvent) => {
if (ev.origin !== windowIso.location.origin) return;
if (typeof ev.data !== 'string' || ev.data !== BillingPaymentActionRedirectPath) return;
this.refreshBillingAfterPaymentClose = true;
this.setState({
paymentActionMessage: 'Action completed',
paymentActionMessageSeverity: 'info',
})
};
!windowIso.isSsr && windowIso.addEventListener('message', this.paymentActionMessageListener);
this.setState({ paymentActionUrl: result.paymentIntent.next_action.redirect_to_url.url });
}
}