@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 vote down vote up
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 vote down vote up
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 vote down vote up
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 vote down vote up
// 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 vote down vote up
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 vote down vote up
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} />)}
        &nbsp;
        {type === 'link'
          ? 'Link'
          : 'Merge'}
        &nbsp;
        <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 vote down vote up
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 vote down vote up
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' />
              &nbsp;&nbsp;
              {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 vote down vote up
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 && (
              <>
                &nbsp;
                <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' />
                &nbsp;&nbsp;
              </>
            )}
            {header.title.title}
            {header.title.help && (
              <>
                &nbsp;
                <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 vote down vote up
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 vote down vote up
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&nbsp;8282&nbsp;8282&nbsp;</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}
          &nbsp;/&nbsp;
          {this.props.accountBilling.payment.expiryYear % 100}
        </span>
      );
    } else {
      cardNumber = (<span className={this.props.classes.blurry}>5200&nbsp;8282&nbsp;8282&nbsp;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&nbsp;<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&nbsp;<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&nbsp;<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 });
  }
}