notistack#WithSnackbarProps TypeScript Examples

The following examples show how to use notistack#WithSnackbarProps. 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: AccountPage.tsx    From clearflask with Apache License 2.0 6 votes vote down vote up
class AccountPage extends Component<Props & ConnectProps & WithStyles<typeof styles, true> & WithSnackbarProps> {
  render() {
    if (!this.props.userMe) {
      return (<ErrorPage msg='You need to log in to see your account details' variant='info' />);
    }

    return (
      <div className={this.props.classes.page}>
        <UserEdit server={this.props.server} userId={this.props.userMe.userId} />
        {this.props.credits && (
          <FundingControl
            server={this.props.server}
            className={this.props.classes.section}
            title='Funded'
            hideIfEmpty
          />
        )}
        {this.props.credits && (
          <TransactionList server={this.props.server} className={this.props.classes.section} />
        )}
        <UserContributions
          sectionClassName={this.props.classes.section}
          server={this.props.server}
          userId={this.props.userMe.userId}
        />
      </div>
    );
  }
}
Example #2
Source File: ServerErrorNotifier.tsx    From clearflask with Apache License 2.0 6 votes vote down vote up
class ServerErrorNotifier extends Component<WithSnackbarProps> {
  unsubscribe?: Unsubscribe;

  componentDidMount() {
    this.unsubscribe = Server._subscribeToErrors((errorMsg, isUserFacing) => {
      console.log("Server error:", errorMsg);
      if (isUserFacing) {
        this.props.enqueueSnackbar(errorMsg, {
          variant: 'error',
          preventDuplicate: false,
          action: (key) => (
            <IconButton aria-label="Close" color="inherit" onClick={() => this.props.closeSnackbar(key)}>
              <CloseIcon fontSize='small' />
            </IconButton>
          ),
        });
      }
    }, 'ServerErrorNotifier');
  }

  componentWillUnmount() {
    this.unsubscribe && this.unsubscribe();
  }

  render() {
    return null;
  }
}
Example #3
Source File: RichEditorInternal.tsx    From clearflask with Apache License 2.0 6 votes vote down vote up
class RichEditorInputRefWrap extends React.Component<PropsRichEditorInputRefWrap & WithStyles<typeof styles, true> & WithSnackbarProps> {
  render() {
    return (
      <RichEditorQuill
        ref={this.props.inputRef as any}
        {...this.props}
      />
    );
  }
}
Example #4
Source File: RichEditorInternal.tsx    From clearflask with Apache License 2.0 5 votes vote down vote up
class RichEditorInternal extends React.Component<PropsRichEditor & Omit<React.ComponentProps<typeof TextField>, 'onChange' | 'inputRef'> & WithStyles<typeof styles, true> & WithSnackbarProps, StateRichEditor> {
  constructor(props) {
    super(props);

    this.state = {
      hasText: (props.defaultValue !== undefined && props.defaultValue !== '')
        || (props.value !== undefined && props.value !== ''),
    };
  }

  render() {
    const { onChange, theme, enqueueSnackbar, closeSnackbar, classes, iAgreeInputIsSanitized, component, ...TextFieldProps } = this.props;

    /**
     * To add single-line support visit https://github.com/quilljs/quill/issues/1432
     * Be careful, when adding keyboard module, handlers somehow stop working.
     */
    if (!TextFieldProps.multiline) {
      throw new Error('RichEditor only supports multiline');
    }

    const shrink = this.state.hasText || this.state.isFocused || false;
    const TextFieldCmpt = component || TextField;
    return (
      <TextFieldCmpt
        className={this.props.classes.textField}
        {...TextFieldProps as any /** Weird issue with variant */}
        InputProps={{
          ...this.props.InputProps || {},
          inputComponent: RichEditorInputRefWrap,
          inputProps: {
            // Anything here will be passed along to RichEditorQuill below
            ...this.props.InputProps?.inputProps || {},
            autoFocusAndSelect: this.props.autoFocusAndSelect,
            uploadImage: this.props.uploadImage,
            classes: this.props.classes,
            theme: theme,
            hidePlaceholder: !shrink && !!this.props.label,
            showControlsImmediately: this.props.showControlsImmediately,
            enqueueSnackbar: enqueueSnackbar,
            closeSnackbar: closeSnackbar,
            onFocus: e => {
              this.setState({ isFocused: true });
              this.props.InputProps?.onFocus?.(e);
            },
            onBlur: e => {
              this.setState({ isFocused: false });
              this.props.InputProps?.onBlur?.(e);
            },
          },
        }}
        onChange={(e) => {
          // Unpack these from the event defined in PropsQuill
          const delta = e.target['delta'];
          const source = e.target['source'];
          const editor = e.target['editor'];

          const hasText = editor.getLength() > 0 ? true : undefined;
          this.props.onChange && this.props.onChange(e, delta, source, editor);
          if (!!this.state.hasText !== !!hasText) {
            this.setState({ hasText });
          }
        }}
        InputLabelProps={{
          shrink,
          ...this.props.InputLabelProps || {},
        }}
      />
    );
  }
}
Example #5
Source File: LogIn.tsx    From clearflask with Apache License 2.0 4 votes vote down vote up
class LogIn extends Component<Props & ConnectProps & WithStyles<typeof styles, true> & WithSnackbarProps & WithMobileDialogProps, State> {
  readonly emailInputRef: React.RefObject<HTMLInputElement> = React.createRef();
  state: State = {};
  externalSubmitEnabled: boolean = false;
  readonly oauthFlow = new OAuthFlow({ accountType: 'user', redirectPath: this.props.demoSsoOauthRedirect ? '/oauth-demo' : '/oauth' });
  oauthListenerUnsubscribe: Unsubscribe | undefined;

  componentWillUnmount() {
    this.oauthListenerUnsubscribe?.();
  }

  render() {
    if (!this.props.open && !this.props.inline) return null;

    const onboarding = this.props.config?.users.onboarding || this.props.onboardBefore;

    const notifOpts: Set<NotificationType> = new Set();
    const oauthOpts: Array<Client.NotificationMethodsOauth> = onboarding?.notificationMethods.oauth || [];
    if (onboarding) {
      // if (onboarding.notificationMethods.mobilePush === true
      //   && (this.props.overrideMobileNotification || MobileNotification.getInstance()).canAskPermission()) {
      //   switch ((this.props.overrideMobileNotification || MobileNotification.getInstance()).getDevice()) {
      //     case Device.Android:
      //       notifOpts.add(NotificationType.Android);
      //       break;
      //     case Device.Ios:
      //       notifOpts.add(NotificationType.Ios);
      //       break;
      //   }
      // }
      if (onboarding.notificationMethods.browserPush === true
        && (this.props.overrideWebNotification || WebNotification.getInstance()).canAskPermission()) {
        notifOpts.add(NotificationType.Browser);
      }
      if (onboarding.notificationMethods.anonymous
        && (onboarding.notificationMethods.anonymous.onlyShowIfPushNotAvailable !== true
          || (!notifOpts.has(NotificationType.Android) && !notifOpts.has(NotificationType.Ios) && !notifOpts.has(NotificationType.Browser)))) {
        notifOpts.add(NotificationType.Silent)
      }
      if (onboarding.notificationMethods.email) {
        notifOpts.add(NotificationType.Email);
      }
      if (onboarding.notificationMethods.sso) {
        notifOpts.add(NotificationType.SSO);
      }
      if (oauthOpts.length > 0) {
        notifOpts.add(NotificationType.OAuth);
      }
    }

    const signupAllowed = notifOpts.size > 0;
    const onlySingleOption = notifOpts.size === 1 && oauthOpts.length <= 1;
    const singleColumnLayout = this.props.fullScreen || onlySingleOption;

    const selectedNotificationType = (this.state.notificationType && notifOpts.has(this.state.notificationType))
      ? this.state.notificationType
      : (onlySingleOption ? notifOpts.values().next().value : undefined);
    const selectedOauthType = selectedNotificationType === NotificationType.OAuth && (this.state.oauthType
      ? this.state.oauthType
      : oauthOpts[0]?.oauthId);

    const emailValid = this.isEmailValid(this.state.email);
    const emailAllowedDomain = this.isAllowedDomain(this.state.email);
    const showDisplayNameInput = signupAllowed && !!onboarding?.accountFields && onboarding.accountFields.displayName !== Client.AccountFieldsDisplayNameEnum.None && selectedNotificationType !== NotificationType.SSO && selectedNotificationType !== NotificationType.OAuth;
    const isDisplayNameRequired = showDisplayNameInput && onboarding?.accountFields?.displayName === Client.AccountFieldsDisplayNameEnum.Required;
    const showPasswordInput = onboarding?.notificationMethods.email && onboarding.notificationMethods.email.password !== Client.EmailSignupPasswordEnum.None;
    const isPasswordRequired = onboarding?.notificationMethods.email && onboarding.notificationMethods.email.password === Client.EmailSignupPasswordEnum.Required;
    const showAccountFields = showPasswordInput || showDisplayNameInput;
    const showEmailInput = selectedNotificationType === NotificationType.Email;
    const showEmailInputInline = !showAccountFields;
    const isSubmittable = selectedNotificationType
      && (selectedNotificationType !== NotificationType.SSO)
      && (selectedNotificationType !== NotificationType.Android || this.state.notificationDataAndroid)
      && (selectedNotificationType !== NotificationType.Ios || this.state.notificationDataIos)
      && (selectedNotificationType !== NotificationType.Browser || this.state.notificationDataBrowser)
      && (!isDisplayNameRequired || this.state.displayName)
      && (selectedNotificationType !== NotificationType.Email || (emailValid && emailAllowedDomain))
      && (!isPasswordRequired || this.state.pass);

    const onlySingleOptionRequiresAllow = onlySingleOption &&
      ((selectedNotificationType === NotificationType.Android && !this.state.notificationDataAndroid)
        || (selectedNotificationType === NotificationType.Ios && !this.state.notificationDataIos)
        || (selectedNotificationType === NotificationType.Browser && !this.state.notificationDataBrowser));

    const doSubmit = async (): Promise<string | undefined> => {
      if (!!this.props.loggedInUser) {
        this.props.onLoggedInAndClose(this.props.loggedInUser.userId);
        return this.props.loggedInUser.userId;
      }
      this.setState({ isSubmitting: true });
      try {
        const userCreateResponse = await (await this.props.server.dispatch()).userCreate({
          projectId: this.props.server.getProjectId(),
          userCreate: {
            name: showDisplayNameInput ? this.state.displayName : undefined,
            email: showEmailInput ? this.state.email : undefined,
            password: (showPasswordInput && this.state.pass) ? saltHashPassword(this.state.pass) : undefined,
            iosPushToken: selectedNotificationType === NotificationType.Ios ? this.state.notificationDataIos : undefined,
            androidPushToken: selectedNotificationType === NotificationType.Android ? this.state.notificationDataAndroid : undefined,
            browserPushToken: selectedNotificationType === NotificationType.Browser ? this.state.notificationDataBrowser : undefined,
          },
        });
        if (userCreateResponse.requiresEmailLogin) {
          return new Promise(resolve => {
            this.setState({
              isSubmitting: false,
              emailLoginDialog: resolve,
            });
          })
        } else if (userCreateResponse.requiresEmailVerification) {
          return new Promise(resolve => {
            this.setState({
              isSubmitting: false,
              emailVerifyDialog: resolve,
            });
          })
        } else {
          this.setState({ isSubmitting: false });
          if (userCreateResponse.user) {
            this.props.onLoggedInAndClose(userCreateResponse.user.userId);
          }
          return userCreateResponse.user?.userId;
        }
      } catch (e) {
        this.setState({ isSubmitting: false });
        throw e;
      }
    };

    if (this.props.externalSubmit && this.externalSubmitEnabled !== isSubmittable) {
      this.externalSubmitEnabled = isSubmittable;
      this.props.externalSubmit(isSubmittable ? doSubmit : undefined);
    }

    const emailInput = !notifOpts.has(NotificationType.Email) ? undefined : (
      <TextField
        classes={{
          root: classNames(!!showEmailInputInline && this.props.classes.emailTextFieldInline),
        }}
        InputLabelProps={{
          classes: {
            root: classNames(!!showEmailInputInline && this.props.classes.emailInputLabelInline),
          },
        }}
        InputProps={{
          classes: {
            notchedOutline: classNames(!!showEmailInputInline && this.props.classes.emailInputInline),
          },
        }}
        inputRef={this.emailInputRef}
        variant='outlined'
        size='small'
        fullWidth
        required={!showEmailInputInline}
        value={this.state.email || ''}
        onChange={e => this.setState({ email: e.target.value })}
        label='Email'
        type='email'
        error={!!this.state.email && (!emailValid || !emailAllowedDomain)}
        helperText={(!!this.props.minimalistic || !!showEmailInputInline) ? undefined : (
          <span className={this.props.classes.noWrap}>
            {!this.state.email || emailAllowedDomain ? 'Where to send you updates' : `Allowed domains: ${onboarding?.notificationMethods.email?.allowedDomains?.join(', ')}`}
          </span>
        )}
        margin='normal'
        style={{ marginTop: showDisplayNameInput ? undefined : '0px' }}
        disabled={this.state.isSubmitting}
      />
    );

    const dialogContent = (
      <>
        <DialogContent className={classNames(
          this.props.className,
          this.props.inline && this.props.classes.contentInline,
        )}>
          {!!this.props.actionTitle && typeof this.props.actionTitle !== 'string' && this.props.actionTitle}
          <div>
            <div
              className={this.props.classes.content}
              style={singleColumnLayout ? { flexDirection: 'column' } : undefined}
            >
              <List component='nav' className={this.props.classes.notificationList}>
                {((!this.props.actionTitle && !this.props.minimalistic) || typeof this.props.actionTitle === 'string') && (
                  <ListSubheader className={this.props.classes.noWrap} component="div">{this.props.actionTitle !== undefined ? this.props.actionTitle : 'Create account'}</ListSubheader>
                )}
                <Collapse mountOnEnter in={notifOpts.has(NotificationType.SSO)}>
                  <ListItem
                    button={!onlySingleOption as any}
                    selected={!onlySingleOption && selectedNotificationType === NotificationType.SSO}
                    onClick={!onlySingleOption ? this.onClickSsoNotif.bind(this) : e => this.setState({ notificationType: NotificationType.SSO })}
                    disabled={this.state.isSubmitting}
                  >
                    <ListItemIcon>
                      {!onboarding?.notificationMethods.sso?.icon
                        ? (<NewWindowIcon />)
                        : (<DynamicMuiIcon name={onboarding?.notificationMethods.sso?.icon} />)}
                    </ListItemIcon>
                    <ListItemText primary={onboarding?.notificationMethods.sso?.buttonTitle
                      || this.props.config?.name
                      || 'External'} />
                  </ListItem>
                  <Collapse mountOnEnter in={onlySingleOption}>
                    <Button color='primary' className={this.props.classes.allowButton} onClick={this.onClickSsoNotif.bind(this)}>Open</Button>
                  </Collapse>
                </Collapse>
                {oauthOpts.map(oauthOpt => (
                  <Collapse mountOnEnter in={notifOpts.has(NotificationType.OAuth)}>
                    <ListItem
                      button={!onlySingleOption as any}
                      selected={!onlySingleOption && selectedNotificationType === NotificationType.OAuth && selectedOauthType === oauthOpt.oauthId}
                      onClick={!onlySingleOption
                        ? e => this.onClickOauthNotif(oauthOpt)
                        : e => this.setState({
                          notificationType: NotificationType.OAuth,
                          oauthType: oauthOpt.oauthId,
                        })}
                      disabled={this.state.isSubmitting}
                    >
                      <ListItemIcon>
                        {!oauthOpt.icon
                          ? (<NewWindowIcon />)
                          : (<DynamicMuiIcon name={oauthOpt.icon} />)}
                      </ListItemIcon>
                      <ListItemText primary={oauthOpt.buttonTitle} />
                    </ListItem>
                    <Collapse mountOnEnter in={onlySingleOption}>
                      <Button color='primary' className={this.props.classes.allowButton} onClick={e => this.onClickOauthNotif(oauthOpt)}>Open</Button>
                    </Collapse>
                  </Collapse>
                ))}
                <Collapse mountOnEnter in={notifOpts.has(NotificationType.Android) || notifOpts.has(NotificationType.Ios)}>
                  <ListItem
                    // https://github.com/mui-org/material-ui/pull/15049
                    button={!onlySingleOption as any}
                    selected={!onlySingleOption && (selectedNotificationType === NotificationType.Android || selectedNotificationType === NotificationType.Ios)}
                    onClick={!onlySingleOption ? this.onClickMobileNotif.bind(this) : undefined}
                    disabled={onlySingleOptionRequiresAllow || this.state.isSubmitting}
                  >
                    <ListItemIcon><MobilePushIcon /></ListItemIcon>
                    <ListItemText primary='Mobile Push' className={this.props.classes.noWrap} />
                  </ListItem>
                  <Collapse mountOnEnter in={onlySingleOptionRequiresAllow}>
                    <Button color='primary' className={this.props.classes.allowButton} onClick={this.onClickMobileNotif.bind(this)}>Allow</Button>
                  </Collapse>
                </Collapse>
                <Collapse mountOnEnter in={notifOpts.has(NotificationType.Browser)}>
                  <ListItem
                    button={!onlySingleOption as any}
                    selected={!onlySingleOption && selectedNotificationType === NotificationType.Browser}
                    onClick={!onlySingleOption ? this.onClickWebNotif.bind(this) : undefined}
                    disabled={onlySingleOptionRequiresAllow || this.state.isSubmitting}
                  >
                    <ListItemIcon><WebPushIcon /></ListItemIcon>
                    <ListItemText primary='Browser Push' className={this.props.classes.noWrap} />
                  </ListItem>
                  <Collapse mountOnEnter in={onlySingleOptionRequiresAllow}>
                    <Button color='primary' className={this.props.classes.allowButton} onClick={this.onClickWebNotif.bind(this)}>Allow</Button>
                  </Collapse>
                </Collapse>
                <Collapse mountOnEnter in={notifOpts.has(NotificationType.Email)}>
                  <ListItem
                    button={!onlySingleOption as any}
                    selected={!onlySingleOption && selectedNotificationType === NotificationType.Email}
                    onClick={!onlySingleOption ? e => {
                      this.setState({ notificationType: NotificationType.Email });
                      this.emailInputRef.current?.focus();
                    } : undefined}
                    disabled={this.state.isSubmitting}
                  >
                    <ListItemIcon><EmailIcon /></ListItemIcon>
                    <ListItemText className={this.props.classes.noWrap} primary={!showEmailInputInline ? 'Email' : emailInput} />
                  </ListItem>
                </Collapse>
                <Collapse mountOnEnter in={notifOpts.has(NotificationType.Silent)}>
                  <ListItem
                    button={!onlySingleOption as any}
                    selected={!onlySingleOption && selectedNotificationType === NotificationType.Silent}
                    onClick={!onlySingleOption ? e => this.setState({ notificationType: NotificationType.Silent }) : undefined}
                    disabled={this.state.isSubmitting}
                  >
                    <ListItemIcon><GuestIcon /></ListItemIcon>
                    <ListItemText primary={this.props.guestLabelOverride || 'Guest'} />
                  </ListItem>
                </Collapse>
                <Collapse mountOnEnter in={!signupAllowed}>
                  <ListItem
                    disabled={true}
                  >
                    <ListItemIcon><DisabledIcon /></ListItemIcon>
                    <ListItemText primary='Sign-up is not available' />
                  </ListItem>
                </Collapse>
              </List>
              <div
                className={this.props.classes.accountFieldsContainer}
                style={{
                  maxWidth: showAccountFields ? '400px' : '0px',
                  maxHeight: showAccountFields ? '400px' : '0px',
                }}
              >
                {!singleColumnLayout && (<Hr vertical isInsidePaper length='25%' />)}
                <div>
                  {!this.props.minimalistic && (
                    <ListSubheader className={this.props.classes.noWrap} component="div">Your info</ListSubheader>
                  )}
                  {showDisplayNameInput && (
                    <TextField
                      variant='outlined'
                      size='small'
                      fullWidth
                      required={isDisplayNameRequired}
                      value={this.state.displayName || ''}
                      onChange={e => this.setState({ displayName: e.target.value })}
                      label='Name'
                      helperText={!!this.props.minimalistic ? undefined : (<span className={this.props.classes.noWrap}>How others see you</span>)}
                      margin='normal'
                      classes={{ root: this.props.classes.noWrap }}
                      style={{ marginTop: '0px' }}
                      disabled={this.state.isSubmitting}
                    />
                  )}
                  <Collapse mountOnEnter in={showEmailInput} unmountOnExit>
                    <div>
                      {!showEmailInputInline && emailInput}
                      {showPasswordInput && (
                        <TextField
                          variant='outlined'
                          size='small'
                          fullWidth
                          required={isPasswordRequired}
                          value={this.state.pass || ''}
                          onChange={e => this.setState({ pass: e.target.value })}
                          label='Password'
                          helperText={!!this.props.minimalistic ? undefined : (
                            <span className={this.props.classes.noWrap}>
                              {isPasswordRequired
                                ? 'Secure your account'
                                : 'Optionally secure your account'}
                            </span>
                          )}
                          type={this.state.revealPassword ? 'text' : 'password'}
                          InputProps={{
                            endAdornment: (
                              <InputAdornment position='end'>
                                <IconButton
                                  aria-label='Toggle password visibility'
                                  onClick={() => this.setState({ revealPassword: !this.state.revealPassword })}
                                >
                                  {this.state.revealPassword ? <VisibilityIcon fontSize='small' /> : <VisibilityOffIcon fontSize='small' />}
                                </IconButton>
                              </InputAdornment>
                            )
                          }}
                          margin='normal'
                          disabled={this.state.isSubmitting}
                        />
                      )}
                    </div>
                  </Collapse>
                </div>
              </div>
            </div>
          </div>
          {signupAllowed && onboarding?.terms?.documents?.length && (
            <AcceptTerms overrideTerms={onboarding.terms.documents} />
          )}
          <Collapse mountOnEnter in={!!this.props.loggedInUser}>
            <DialogContentText>You are logged in as <span className={this.props.classes.bold}>{this.props.loggedInUser?.name || this.props.loggedInUser?.email || 'Anonymous'}</span></DialogContentText>
          </Collapse>
        </DialogContent>
        {!this.props.externalSubmit && (
          <DialogActions>
            {!!this.props.loggedInUser && !!this.props.onClose && (
              <Button onClick={this.props.onClose.bind(this)}>Cancel</Button>
            )}
            {!!signupAllowed ? (
              <SubmitButton
                color='primary'
                isSubmitting={this.state.isSubmitting}
                disabled={!isSubmittable && !this.props.loggedInUser}
                onClick={doSubmit}
              >{this.props.actionSubmitTitle || 'Continue'}</SubmitButton>
            ) : (!!this.props.onClose ? (
              <Button onClick={() => { this.props.onClose?.() }}>Back</Button>
            ) : null)}
          </DialogActions>
        )}
        <Dialog
          open={!!this.state.awaitExternalBind}
          onClose={() => this.setState({ awaitExternalBind: undefined })}
          maxWidth='xs'
          {...this.props.forgotEmailDialogProps}
        >
          <DialogTitle>Awaiting confirmation...</DialogTitle>
          <DialogContent>
            {this.state.awaitExternalBind === 'recovery' ? (
              <DialogContentText>We sent an email to <span className={this.props.classes.bold}>{this.state.email}</span>. Return to this page after clicking the confirmation link.</DialogContentText>
            ) : (
              <DialogContentText>A popup was opened leading you to a sign-in page. After you complete sign-in, this dialog will automatically close.</DialogContentText>
            )}
          </DialogContent>
          <DialogActions>
            <Button onClick={() => this.setState({ awaitExternalBind: undefined })}>Cancel</Button>
          </DialogActions>
        </Dialog>
        <Dialog
          open={!!this.state.emailLoginDialog}
          onClose={() => {
            this.state.emailLoginDialog?.();
            this.setState({ emailLoginDialog: undefined })
          }}
          maxWidth='xs'
        >
          <DialogTitle>Login via Email</DialogTitle>
          <DialogContent>
            <DialogContentText>The email <span className={this.props.classes.bold}>{this.state.email}</span> is associated with an account.</DialogContentText>
            <DialogContentText>Open the link from the email or copy the verification token here:</DialogContentText>
            <DigitsInput
              digits={6}
              value={this.state.emailLoginToken}
              disabled={this.state.isSubmitting}
              onChange={(val, isComplete) => {
                if (isComplete) {
                  this.setState({
                    emailLoginToken: val,
                    isSubmitting: true,
                  }, () => setTimeout(() => {
                    this.props.server.dispatch().then(d => d.userLogin({
                      projectId: this.props.server.getProjectId(),
                      userLogin: {
                        email: this.state.email!,
                        token: val.join(''),
                      },
                    })).then(user => {
                      this.state.emailLoginDialog?.(user.userId);
                      this.setState({
                        isSubmitting: false,
                        emailLoginDialog: undefined,
                      });
                      this.props.onLoggedInAndClose(user.userId);
                    }).catch(() => {
                      this.setState({ isSubmitting: false });
                    });
                  }, 1));
                } else {
                  this.setState({ emailLoginToken: val });
                }
              }}
            />
          </DialogContent>
          <DialogActions>
            <Button onClick={() => {
              this.state.emailLoginDialog?.();
              this.setState({ emailLoginDialog: undefined })
            }}>Cancel</Button>
          </DialogActions>
        </Dialog>
        <Dialog
          open={!!this.state.emailVerifyDialog}
          onClose={() => {
            this.state.emailVerifyDialog?.();
            this.setState({ emailVerifyDialog: undefined });
          }}
          maxWidth='xs'
        >
          <DialogTitle>Verify your email</DialogTitle>
          <DialogContent>
            <DialogContentText>We sent a verification email to <span className={this.props.classes.bold}>{this.state.email}</span>. Please copy the verification token from the email here:</DialogContentText>
            <DigitsInput
              digits={6}
              value={this.state.emailVerification}
              disabled={this.state.isSubmitting}
              onChange={(val, isComplete) => {
                if (isComplete) {
                  this.setState({
                    emailVerification: val,
                    isSubmitting: true,
                  }, () => setTimeout(() => {
                    this.props.server.dispatch().then(d => d.userCreate({
                      projectId: this.props.server.getProjectId(),
                      userCreate: {
                        name: this.state.displayName,
                        email: this.state.email!,
                        emailVerification: val.join(''),
                        password: this.state.pass ? saltHashPassword(this.state.pass) : undefined,
                      },
                    })).then(userCreateResponse => {
                      if (userCreateResponse.requiresEmailVerification || !userCreateResponse.user) {
                        this.setState({ isSubmitting: false });
                      } else {
                        this.state.emailVerifyDialog?.(userCreateResponse.user.userId);
                        this.setState({
                          isSubmitting: false,
                          emailVerifyDialog: undefined,
                        });
                        this.props.onLoggedInAndClose(userCreateResponse.user.userId);
                      }
                    }).catch(() => {
                      this.setState({ isSubmitting: false });
                    });
                  }, 1));
                } else {
                  this.setState({ emailVerification: val });
                }
              }}
            />
          </DialogContent>
          <DialogActions>
            <Button onClick={() => {
              this.state.emailVerifyDialog?.();
              this.setState({ emailVerifyDialog: undefined })
            }}>Cancel</Button>
          </DialogActions>
        </Dialog>
      </>
    );

    return this.props.inline ? (
      <Collapse mountOnEnter in={!!this.props.open}>
        {dialogContent}
      </Collapse>
    ) : (
      <Dialog
        open={!!this.props.open}
        onClose={this.props.onClose}
        scroll='body'
        PaperProps={{
          style: {
            width: 'fit-content',
            marginLeft: 'auto',
            marginRight: 'auto',
          },
        }}
        {...this.props.DialogProps}
      >
        {dialogContent}
      </Dialog>
    );
  }

  isEmailValid(email?: string): boolean {
    if (!email) return false;
    const atIndex = email.indexOf('@');
    if (atIndex <= 0 || atIndex + 1 >= email.length) return false;
    return true;
  }

  isAllowedDomain(email?: string) {
    if (!email) return false;
    const onboarding = this.props.config?.users.onboarding || this.props.onboardBefore;
    if (onboarding?.notificationMethods.email?.allowedDomains) {
      return onboarding.notificationMethods.email.allowedDomains
        .some(allowedDomain => email.trim().endsWith(`@${allowedDomain}`));
    }
    return true;
  }

  listenForExternalBind() {
    this.oauthListenerUnsubscribe = this.oauthFlow.listenForSuccess(() => {
      if (detectEnv() === Environment.DEVELOPMENT_FRONTEND) {
        this.props.server.dispatch().then(d => d.userCreate({
          projectId: this.props.server.getProjectId(),
          userCreate: {
            email: '[email protected]',
            name: 'Matus Faro',
            ...{
              isExternal: true, // Only used during development, disregarded otherwise
            },
          },
        }));
      } else {
        this.props.server.dispatch().then(d => d.userBind({
          projectId: this.props.server.getProjectId(),
          userBind: {},
        }));
      }
    });
  }

  onClickOauthNotif(oauthConfig: Client.NotificationMethodsOauth) {
    this.setState({ awaitExternalBind: 'oauth' });
    this.listenForExternalBind();
    this.oauthFlow.open(oauthConfig, 'window');
  }

  onClickSsoNotif() {
    const onboarding = this.props.config?.users.onboarding || this.props.onboardBefore;
    if (!onboarding?.notificationMethods.sso?.redirectUrl) return;
    this.listenForExternalBind();
    this.setState({ awaitExternalBind: 'sso' });
    !windowIso.isSsr && windowIso.open(onboarding.notificationMethods.sso.redirectUrl
      .replace('<return_uri>', `${windowIso.location.protocol}//${windowIso.location.host}${this.props.demoSsoOauthRedirect ? '/sso-demo' : '/sso'}`),
      `cf_${this.props.server.getProjectId()}_sso`,
      `width=${windowIso.document.documentElement.clientWidth * 0.9},height=${windowIso.document.documentElement.clientHeight * 0.9}`,
    );
  }

  onClickMobileNotif() {
    const device = (this.props.overrideMobileNotification || MobileNotification.getInstance()).getDevice();
    if (device === Device.None) return;
    this.setState({
      notificationType: device === Device.Android ? NotificationType.Android : NotificationType.Ios,
    });
    (this.props.overrideMobileNotification || MobileNotification.getInstance()).askPermission().then(r => {
      if (r.type === 'success') {
        this.setState({
          ...(r.device === Device.Android ? { notificationDataAndroid: r.token } : {}),
          ...(r.device === Device.Ios ? { notificationDataIos: r.token } : {}),
        });
      } else if (r.type === 'error') {
        if (r.userFacingMsg) {
          this.props.enqueueSnackbar(r.userFacingMsg || 'Failed to setup mobile push', { variant: 'error', preventDuplicate: true });
        }
        this.forceUpdate();
      }
    })
  }

  onClickWebNotif() {
    this.setState({
      notificationType: NotificationType.Browser,
    });
    (this.props.overrideWebNotification || WebNotification.getInstance()).askPermission().then(r => {
      if (r.type === 'success') {
        this.setState({
          notificationDataBrowser: r.token,
        });
      } else if (r.type === 'error') {
        if (r.userFacingMsg) {
          this.props.enqueueSnackbar(r.userFacingMsg || 'Failed to setup browser notifications', { variant: 'error', preventDuplicate: true });
        }
        this.forceUpdate();
      }
    });
  }
}
Example #6
Source File: Post.tsx    From clearflask with Apache License 2.0 4 votes vote down vote up
class Post extends Component<Props & ConnectProps & WithTranslation<'app'> & WithStyles<typeof styles, true> & WithSnackbarProps, State> {
  onLoggedIn?: () => void;
  _isMounted: boolean = false;
  readonly fundingControlRef = createMutableRef<any>();
  readonly inViewObserverRef = React.createRef<InViewObserver>();
  priorToExpandDocumentTitle: string | undefined;

  constructor(props) {
    super(props);

    this.state = {
      currentVariant: props.variant,
    };

    // Refresh votes from server if missing
    if (props.idea
      && props.voteStatus === undefined
      // Don't refresh votes if inside a panel which will refresh votes for us
      && props.variant === 'page'
      && props.loggedInUser) {
      props.server.dispatch().then(d => d.ideaVoteGetOwn({
        projectId: props.projectId,
        ideaIds: [props.idea!.ideaId],
        myOwnIdeaIds: props.idea!.authorUserId === props.loggedInUser!.userId
          ? [props.idea!.ideaId] : [],
      }));
    }

    if (props.fetchPostIds?.length) {
      props.server.dispatch({ ssr: true }).then(d => d.ideaGetAll({
        projectId: props.projectId,
        ideaGetAll: {
          postIds: props.fetchPostIds!,
        },
      }));
    }
  }

  shouldComponentUpdate = customShouldComponentUpdate({
    nested: new Set(['display', 'idea']),
    presence: new Set(['onClickTag', 'onClickCategory', 'onClickStatus', 'onClickPost', 'onUserClick', 'onDisconnect']),
    ignored: new Set(['fetchPostIds']),
  });

  componentDidMount() {
    this._isMounted = true;
    if (!!this.props.settings.demoFundingControlAnimate) {
      this.demoFundingControlAnimate(this.props.settings.demoFundingControlAnimate);
    } else if (!!this.props.settings.demoFundingAnimate) {
      this.demoFundingAnimate(this.props.settings.demoFundingAnimate);
    }
    if (!!this.props.settings.demoVotingExpressionsAnimate) {
      this.demoVotingExpressionsAnimate(this.props.settings.demoVotingExpressionsAnimate);
    }
  }

  componentWillUnmount() {
    this._isMounted = false;
  }

  render() {
    if (!this.props.idea) return (
      <Loading />
    );

    const isOnlyPostOnClick = (!!this.props.onClickPost && !this.props.onClickTag && !this.props.onClickCategory && !this.props.onClickStatus && !this.props.onUserClick);
    return (
      <div className={classNames(this.props.className)}>
        <InViewObserver ref={this.inViewObserverRef} disabled={
          !this.props.settings.demoFundingControlAnimate && !this.props.settings.demoFundingAnimate && !this.props.settings.demoVotingExpressionsAnimate
        }>
          <div
            className={classNames(
              this.props.classes.post,
              this.props.classNamePadding || this.props.classes.postPadding,
              (isOnlyPostOnClick && !this.props.disableOnClick) && this.props.classes.clickable,
            )}
            style={{
              minWidth: MinContentWidth,
              width: this.props.widthExpand ? MaxContentWidth : (this.props.variant !== 'list' ? MaxContentWidth : MinContentWidth),
              maxWidth: this.props.widthExpand ? '100%' : MaxContentWidth,
            }}
            onClick={(isOnlyPostOnClick && !this.props.disableOnClick) ? () => this.props.onClickPost && !!this.props.idea?.ideaId && this.props.onClickPost(this.props.idea.ideaId) : undefined}
          >
            <div className={this.props.classes.postFunding}>
              {this.renderFunding()}
            </div>
            <div className={classNames(
              this.props.classes.postContent,
              this.props.postContentSingleLine && this.props.classes.postContentSingleLine,
            )}>
              {this.renderTitleAndDescription((
                <>
                  {this.renderHeader()}
                  {this.renderCover()}
                  {this.renderTitle()}
                  {this.renderDescription()}
                </>
              ), isOnlyPostOnClick)}
              {this.props.postContentSingleLine && (<div className={this.props.classes.postContentSingleLineDivider} />)}
              {this.renderBottomBar()}
              {this.renderIWantThisCommentAdd()}
              {this.renderResponseAndStatus()}
              {this.renderMerged()}
              {this.renderLinkedToGitHub()}
              {this.renderLinks()}
            </div>
            {this.props.contentBeforeComments && (
              <div className={this.props.classes.postContentBeforeComments}>
                {this.props.contentBeforeComments}
              </div>
            )}
          </div>
          <div className={this.props.classes.postComments}>
            {this.renderComments()}
          </div>
          <LogIn
            actionTitle={this.props.t('get-notified-of-updates')}
            server={this.props.server}
            open={this.state.logInOpen}
            onClose={() => this.setState({ logInOpen: false })}
            onLoggedInAndClose={() => {
              this.setState({ logInOpen: false });
              this.onLoggedIn && this.onLoggedIn();
              this.onLoggedIn = undefined;
            }}
          />
        </InViewObserver>
      </div>
    );
  }

  renderBottomBar() {
    var leftSide: React.ReactNode[] | undefined;
    var rightSide: React.ReactNode[] | undefined;

    if (this.props.variant !== 'list') {
      leftSide = [
        this.renderVoting(),
        this.renderExpression(),
      ].filter(notEmpty);

      rightSide = [
        this.renderDisconnect(),
        this.renderRespond(),
        this.renderCommentAdd(),
        this.renderConnect(),
        this.renderDelete(),
      ].filter(notEmpty);
    } else {
      leftSide = [
        this.renderVoting() || this.renderVotingCount(),
        this.renderExpressionCount(),
        this.renderCommentCount(),
      ].filter(notEmpty);

      rightSide = [
        this.renderStatus(),
        this.renderTags(),
        this.renderCategory(),
        this.renderDisconnect(),
      ].filter(notEmpty);
    }

    if ((leftSide?.length || 0) + (rightSide?.length || 0) === 0) return null;

    return (
      <div className={this.props.classes.bottomBarLine}>
        <div className={this.props.classes.bottomBarLine}>
          <Delimited delimiter={(<>&nbsp;&nbsp;&nbsp;</>)}>
            {leftSide}
          </Delimited>
        </div>
        <div className={this.props.classes.grow} />
        <div className={this.props.classes.bottomBarLine}>
          <Delimited delimiter={(<>&nbsp;&nbsp;&nbsp;</>)}>
            {rightSide}
          </Delimited>
        </div>
      </div>
    );
  }

  renderHeader() {
    var header: React.ReactNode[] | undefined;

    if (this.props.variant !== 'list') {
      header = [
        this.renderAuthor(),
        this.renderCreatedDatetime(),
        this.renderStatus(),
        this.renderTags(),
        this.renderCategory(),
      ].filter(notEmpty);
    } else {
      header = [
        this.renderAuthor(),
        this.renderCreatedDatetime(),
      ].filter(notEmpty);
    }

    if (!header.length) return null;

    return (
      <div className={this.props.classes.headerBarLine}>
        <Delimited delimiter={(<>&nbsp;&nbsp;&nbsp;</>)}>
          {header}
        </Delimited>
      </div>
    );
  }

  renderAuthor() {
    if (this.props.variant === 'list' && this.props.display && this.props.display.showAuthor === false
      || !this.props.idea?.authorUserId) return null;

    return (
      <Typography key='author' className={this.props.classes.author} variant='caption'>
        <UserWithAvatarDisplay
          onClick={this.props.disableOnClick ? this.props.onUserClick : undefined}
          user={{
            userId: this.props.idea.authorUserId,
            name: this.props.idea.authorName,
            isMod: this.props.idea.authorIsMod
          }}
          baseline
        />
      </Typography>
    );
  }

  renderCreatedDatetime() {
    if (this.props.variant === 'list' && this.props.display && this.props.display.showCreated === false
      || !this.props.idea?.created) return null;

    return (
      <Typography key='createdDatetime' className={this.props.classes.timeAgo} variant='caption'>
        <TimeAgo date={this.props.idea.created} />
      </Typography>
    );
  }

  renderCommentCount() {
    if (this.props.display?.showCommentCount === false
      || this.props.variant !== 'list'
      || !this.props.idea
      || (this.props.display?.showCommentCount === undefined && !this.props.idea.commentCount)
      || !this.props.category
      || !this.props.category.support.comment) return null;

    return (
      <Typography key='commentCount' className={this.props.classes.itemCount} variant='caption'>
        <SpeechIcon fontSize='inherit' />
        &nbsp;
        {this.props.idea.commentCount || 0}
      </Typography>
    );
  }

  renderCommentAdd() {
    if (this.props.variant === 'list'
      || !this.props.idea
      || !this.props.category
      || !this.props.category.support.comment) return null;
    const commentsAllowed: boolean = !this.props.idea.statusId
      || this.props.category.workflow.statuses.find(s => s.statusId === this.props.idea!.statusId)?.disableComments !== true;
    if (!commentsAllowed) return null;

    return (
      <MyButton
        key='addComment'
        buttonVariant='post'
        Icon={SpeechIcon}
        disabled={!!this.state.commentExpanded}
        onClick={e => this.setState({ commentExpanded: true })}
      >
        {this.props.t('comment')}
      </MyButton>
    );
  }

  renderIWantThisCommentAdd() {
    if (!this.props.category?.support.vote?.iWantThis
      || !this.props.idea?.ideaId
      || !this.shouldRenderVoting()
      || !this.areCommentsAllowed()
    ) return null;

    return (
      <CommentReply
        server={this.props.server}
        ideaId={this.props.idea.ideaId}
        collapseIn={!!this.state.iWantThisCommentExpanded}
        focusOnIn
        logIn={this.logIn.bind(this)}
        inputLabel={this.props.t(this.props.category.support.vote.iWantThis.encourageLabel as any || 'tell-us-why')}
        onSubmitted={() => this.setState({ iWantThisCommentExpanded: undefined })}
        onBlurAndEmpty={() => this.setState({ iWantThisCommentExpanded: undefined })}
      />
    );
  }

  areCommentsAllowed() {
    return !this.props.idea?.statusId
      || this.props.category?.workflow.statuses.find(s => s.statusId === this.props.idea!.statusId)?.disableComments !== true;
  }

  logIn() {
    if (this.props.loggedInUser) {
      return Promise.resolve();
    } else {
      return new Promise<void>(resolve => {
        this.onLoggedIn = resolve
        this.setState({ logInOpen: true });
      });
    }
  }

  renderComments() {
    if (this.props.variant === 'list'
      || !this.props.idea?.ideaId
      || !this.props.category
      || !this.props.category.support.comment) return null;

    const commentsAllowed: boolean = this.areCommentsAllowed();

    return (
      <div key='comments' className={this.props.classes.commentSection}>
        {commentsAllowed && (
          <CommentReply
            server={this.props.server}
            ideaId={this.props.idea.ideaId}
            collapseIn={!!this.state.commentExpanded}
            focusOnIn
            logIn={this.logIn.bind(this)}
            onSubmitted={() => this.setState({ commentExpanded: undefined })}
            onBlurAndEmpty={() => this.setState({ commentExpanded: undefined })}
          />
        )}
        {(!!this.props.idea.commentCount || !!this.props.idea.mergedPostIds?.length) && (
          <CommentList
            server={this.props.server}
            logIn={this.logIn.bind(this)}
            ideaId={this.props.idea.ideaId}
            expectedCommentCount={this.props.idea.childCommentCount + (this.props.idea.mergedPostIds?.length || 0)}
            parentCommentId={undefined}
            newCommentsAllowed={commentsAllowed}
            loggedInUser={this.props.loggedInUser}
            disableOnClick={this.props.disableOnClick}
            onAuthorClick={(this.props.onUserClick && !this.props.disableOnClick) ? (commentId, userId) => this.props.onUserClick && this.props.onUserClick(userId) : undefined}
          />
        )}
      </div>
    );
  }

  renderRespond() {
    const isMod = this.props.server.isModOrAdminLoggedIn();
    if (this.props.variant === 'list'
      || !this.props.idea
      || !this.props.category
      || !isMod
      || !!this.props.idea.response
      || this.props.display?.showEdit === false) return null;

    return (
      <React.Fragment key='edit-response'>
        <MyButton
          buttonVariant='post'
          disabled={!!this.state.showEditingStatusAndResponse}
          Icon={RespondIcon}
          onClick={e => this.setState({ showEditingStatusAndResponse: 'response' })}
        >
          {this.props.t('respond')}
        </MyButton>
      </React.Fragment>
    );
  }

  renderConnect() {
    const isMod = this.props.server.isModOrAdminLoggedIn();
    if (this.props.variant === 'list'
      || !this.props.idea?.ideaId
      || !this.props.category
      || !isMod
      || this.props.display?.showEdit === false) return null;

    return (
      <React.Fragment key='edit-connect'>
        <MyButton
          buttonVariant='post'
          disabled={!!this.state.showEditingConnect}
          Icon={LinkAltIcon}
          onClick={e => this.setState({ showEditingConnect: true })}
        >
          {this.props.t('link')}
        </MyButton>
        <Provider key={this.props.server.getProjectId()} store={this.props.server.getStore()}>
          <PostConnectDialog
            server={this.props.server}
            post={this.props.idea}
            open={!!this.state.showEditingConnect}
            onClose={() => this.setState({ showEditingConnect: false })}
          />
        </Provider>
      </React.Fragment>
    );
  }

  renderDelete() {
    const isMod = this.props.server.isModOrAdminLoggedIn();
    if (this.props.variant === 'list'
      || !this.props.idea?.ideaId
      || !isMod) return null;

    return (
      <>
        <MyButton
          key='delete'
          buttonVariant='post'
          Icon={DeleteIcon}
          onClick={e => this.setState({ deleteDialogOpen: true })}
        >
          {this.props.t('delete')}
        </MyButton>
        <Dialog
          open={!!this.state.deleteDialogOpen}
          onClose={() => this.setState({ deleteDialogOpen: false })}
        >
          <DialogTitle>{this.props.t('delete-post')}</DialogTitle>
          <DialogContent>
            <DialogContentText>{this.props.t('are-you-sure-permanently-delete-post')}</DialogContentText>
          </DialogContent>
          <DialogActions>
            <Button onClick={() => this.setState({ deleteDialogOpen: false })}
            >{this.props.t('cancel')}</Button>
            <SubmitButton
              isSubmitting={this.state.isSubmittingDelete}
              style={{ color: !this.state.isSubmittingDelete ? this.props.theme.palette.error.main : undefined }}
              onClick={async () => {
                this.setState({ isSubmittingDelete: true });
                try {
                  if (!this.props.idea?.ideaId) return;
                  await (await this.props.server.dispatchAdmin()).ideaDeleteAdmin({
                    projectId: this.props.server.getProjectId(),
                    ideaId: this.props.idea.ideaId,
                  });
                  this.props.onDeleted?.();
                } finally {
                  this.setState({ isSubmittingDelete: false });
                }
              }}>
              {this.props.t('delete')}
            </SubmitButton>
          </DialogActions>
        </Dialog>
      </>
    );
  }

  renderStatus(isInResponse?: boolean, isEditing?: boolean) {
    if (this.props.variant === 'list' && this.props.display && this.props.display.showStatus === false
      || !this.props.idea
      || !this.props.idea.statusId
      || !this.props.category) return null;

    const status = this.props.category.workflow.statuses.find(s => s.statusId === this.props.idea!.statusId);

    var content;

    if (isEditing && !!this.props.idea.categoryId) {
      content = (
        <PostEditStatus
          server={this.props.server}
          autoFocusAndSelect={this.state.showEditingStatusAndResponse === 'status'}
          categoryId={this.props.idea.categoryId}
          initialValue={this.props.idea.statusId}
          value={this.state.editingStatusId}
          onChange={statusId => this.setState({ editingStatusId: statusId })}
          isSubmitting={this.state.isSubmittingStatusAndResponse}
          bare
        />
      );
    } else {
      if (!status) return null;
      content = status.name;
    }

    content = (
      <Typography variant={isInResponse ? 'h6' : 'caption'} component='div' style={{ color: status?.color }}>
        {content}
      </Typography>
    );

    if (this.props.onClickStatus && !this.props.disableOnClick && status && !isEditing) {
      content = (
        <Button
          key='status'
          variant='text'
          className={this.props.classes.button}
          disabled={!this.props.onClickStatus || this.props.disableOnClick || this.props.variant !== 'list'}
          onClick={e => status && this.props.onClickStatus && !this.props.disableOnClick && this.props.onClickStatus(status.statusId)}
        >
          {content}
        </Button>
      );
    }

    if (this.props.variant !== 'list' && this.canEdit() === 'mod') {
      content = (
        <ClickToEdit isEditing={!!this.state.showEditingStatusAndResponse} setIsEditing={isEditing => this.setState({ showEditingStatusAndResponse: 'status' })} >
          {content}
        </ClickToEdit>
      );
    }

    return content;
  }

  renderTags() {
    const canEdit = this.canEdit() === 'mod';
    if (this.props.variant === 'list' && this.props.display && this.props.display.showTags === false
      || !this.props.idea?.ideaId
      || !this.props.category
      || (!this.props.idea.tagIds.length && !canEdit)) return null

    var contentTags = this.props.idea.tagIds
      .map(tagId => this.props.category!.tagging.tags.find(t => t.tagId === tagId))
      .filter(tag => !!tag)
      .map(tag => (
        <Button key={'tag' + tag!.tagId} variant="text" className={this.props.classes.button} disabled={!this.props.onClickTag || this.props.disableOnClick || this.props.variant !== 'list'}
          onClick={e => this.props.onClickTag && !this.props.disableOnClick && this.props.onClickTag(tag!.tagId)}>
          <Typography variant='caption' style={{ color: tag!.color }}>
            {tag!.name}
          </Typography>
        </Button>
      ));

    var content: React.ReactNode;
    if (canEdit) {
      content = (
        <PostEditTagsInline
          server={this.props.server}
          post={this.props.idea}
          bare
          noContentLabel={(
            <Typography variant='caption' className={this.props.classes.noContentLabel}
            >{this.props.t('add-tags')}</Typography>
          )}
        >
          {contentTags.length ? contentTags : null}
        </PostEditTagsInline >
      );
    } else {
      content = contentTags;
    }

    return content;
  }

  renderCategory() {
    // Don't show unlesss explictly asked for
    if (this.props.display?.showCategoryName !== true
      || !this.props.idea
      || !this.props.category) return null;

    return (
      <PostClassification
        title={this.props.category.name}
        color={this.props.category.color}
        onClick={!this.props.onClickCategory || this.props.disableOnClick || this.props.variant !== 'list' ? undefined
          : (() => this.props.onClickCategory && !this.props.disableOnClick && this.props.onClickCategory(this.props.category!.categoryId))}
      />
    );
  }

  renderVotingCount() {
    if ((this.props.variant === 'list' && this.props.display?.showVoting === true)
      || this.props.variant !== 'list'
      || this.props.display?.showVotingCount === false
      || !this.props.idea
      || !this.props.category
      || !this.props.category.support.vote
      || (this.props.display?.showVotingCount === undefined && (this.props.idea.voteValue || 1) === 1)
    ) return null;

    return (
      <VotingControl
        onlyShowCount
        className={this.props.classes.itemCount}
        vote={this.props.vote}
        voteValue={this.props.idea?.voteValue || 0}
        isSubmittingVote={this.state.isSubmittingVote}
        iWantThis={this.props.category?.support.vote?.iWantThis}
      />
    );
  }

  shouldRenderVoting(): boolean {
    return !((this.props.variant === 'list' && this.props.display?.showVoting !== true)
      || !this.props.idea
      || !this.props.category
      || !this.props.category.support.vote
    );
  }
  renderVoting() {
    if (!this.shouldRenderVoting()) return null;
    const votingAllowed: boolean = !this.props.idea?.statusId
      || this.props.category?.workflow.statuses.find(s => s.statusId === this.props.idea!.statusId)?.disableVoting !== true;

    return (
      <div
        className={classNames(
          this.props.classes.votingControl,
          !!this.props.settings.demoFlashPostVotingControls && (this.state.demoFlashPostVotingControlsHovering === undefined ? this.props.classes.pulsateVoting
            : (this.state.demoFlashPostVotingControlsHovering === 'vote' ? this.props.classes.pulsateShown : this.props.classes.pulsateHidden)))}
        onMouseOver={!!this.props.settings.demoFlashPostVotingControls ? () => this.setState({ demoFlashPostVotingControlsHovering: 'vote' }) : undefined}
        onMouseOut={!!this.props.settings.demoFlashPostVotingControls ? () => this.setState({ demoFlashPostVotingControlsHovering: undefined }) : undefined}
      >
        <VotingControl
          className={this.props.classes.votingControl}
          vote={this.props.vote}
          voteValue={this.props.idea?.voteValue || 0}
          isSubmittingVote={this.state.isSubmittingVote}
          votingAllowed={votingAllowed}
          onUpvote={() => this.upvote()}
          iWantThis={this.props.category?.support.vote?.iWantThis}
          onDownvote={!this.props.category?.support.vote?.enableDownvotes ? undefined : () => this.downvote()}
        />
      </div>
    );
  }

  upvote() {
    const upvote = () => {
      if (this.state.isSubmittingVote) return;
      if (!!this.props.category?.support.vote?.iWantThis
        && this.props.vote !== Client.VoteOption.Upvote) {
        this.setState({ iWantThisCommentExpanded: true });
      }
      this.setState({ isSubmittingVote: Client.VoteOption.Upvote });
      this.updateVote({
        vote: (this.props.vote === Client.VoteOption.Upvote)
          ? Client.VoteOption.None : Client.VoteOption.Upvote
      })
        .then(() => this.setState({ isSubmittingVote: undefined }),
          () => this.setState({ isSubmittingVote: undefined }));
    };
    if (this.props.loggedInUser) {
      upvote();
    } else {
      this.onLoggedIn = upvote;
      this.setState({ logInOpen: true });
    }
  }

  downvote() {
    const downvote = () => {
      if (this.state.isSubmittingVote) return;
      if (!!this.props.category?.support.vote?.iWantThis
        && this.props.vote !== Client.VoteOption.Downvote) {
        this.setState({ iWantThisCommentExpanded: true });
      }
      this.setState({ isSubmittingVote: Client.VoteOption.Downvote });
      this.updateVote({
        vote: (this.props.vote === Client.VoteOption.Downvote)
          ? Client.VoteOption.None : Client.VoteOption.Downvote
      })
        .then(() => this.setState({ isSubmittingVote: undefined }),
          () => this.setState({ isSubmittingVote: undefined }));
    };
    if (this.props.loggedInUser) {
      downvote();
    } else {
      this.onLoggedIn = downvote;
      this.setState({ logInOpen: true });
    }
  }

  fundingExpand(callback?: () => void) {
    this.setState({
      fundingExpanded: true,
    }, callback);
  }

  fundingBarRef: React.RefObject<HTMLDivElement> = React.createRef();
  renderFunding() {
    if (this.props.variant === 'list' && this.props.display && this.props.display.showFunding === false
      || !this.props.idea?.ideaId
      || !this.props.credits
      || !this.props.category
      || !this.props.category.support.fund) return null;
    const fundingAllowed = !this.props.idea.statusId
      || this.props.category.workflow.statuses.find(s => s.statusId === this.props.idea!.statusId)?.disableFunding !== true;
    if (!fundingAllowed
      && !this.props.idea.fundGoal
      && !this.props.idea.funded
      && !this.props.idea.fundersCount) return null;

    const iFundedThis = !!this.props.fundAmount && this.props.fundAmount > 0;

    const fundThisButton = (
      <Button
        color={iFundedThis ? 'primary' : 'default'}
        classes={{
          root: `${this.props.classes.button} ${this.props.classes.fundThisButton}`,
        }}
        disabled={!fundingAllowed}
        onClick={!fundingAllowed ? undefined : (e => {
          const onLoggedInClick = () => {
            this.fundingExpand();
          };
          if (this.props.loggedInUser) {
            onLoggedInClick();
          } else {
            this.onLoggedIn = onLoggedInClick;
            this.setState({ logInOpen: true });
          }
        })}
      >
        <Typography
          variant='caption'
          className={this.props.classes.fundThisButtonLabel}
          color={fundingAllowed ? 'primary' : 'inherit'}
        >
          {fundingAllowed
            ? <span style={{ display: 'flex', alignItems: 'center' }}>
              <AddIcon fontSize='inherit' />
              {iFundedThis ? 'Adjust' : 'Fund'}
            </span>
            : 'Closed'}
        </Typography>
      </Button>
    );

    return (
      <div style={{ display: 'flex' }}>
        {fundingAllowed && (
          <ClosablePopper
            anchorType='in-place'
            clickAway
            open={!!this.state.fundingExpanded}
            onClose={() => this.setState({ fundingExpanded: false })}
            className={this.props.classes.fundingPopper}
          >
            <div className={classNames(this.props.classes.funding, this.props.classes.fundingPopperPaper)}>
              <FundingControl
                myRef={this.fundingControlRef}
                server={this.props.server}
                ideaId={this.props.idea.ideaId}
                maxOther={2}
                isInsidePaper
              />
            </div>
          </ClosablePopper>
        )}
        <div
          className={classNames(
            this.props.classes.funding,
            !!this.props.settings.demoFlashPostVotingControls && (this.state.demoFlashPostVotingControlsHovering === undefined ? this.props.classes.pulsateFunding
              : (this.state.demoFlashPostVotingControlsHovering === 'fund' ? this.props.classes.pulsateShown : this.props.classes.pulsateHidden)))}
          onMouseOver={!!this.props.settings.demoFlashPostVotingControls ? () => this.setState({ demoFlashPostVotingControlsHovering: 'fund' }) : undefined}
          onMouseOut={!!this.props.settings.demoFlashPostVotingControls ? () => this.setState({ demoFlashPostVotingControlsHovering: undefined }) : undefined}
        >
          <FundingBar
            fundingBarRef={this.fundingBarRef}
            idea={this.props.idea}
            credits={this.props.credits}
            maxFundAmountSeen={this.props.maxFundAmountSeen}
            overrideRight={fundThisButton}
          />
        </div>
      </div>
    );
  }

  renderExpressionEmoji(key: string, display: string | React.ReactNode, hasExpressed: boolean, onLoggedInClick: ((currentTarget: HTMLElement) => void) | undefined = undefined, count: number = 0) {
    return (
      <Chip
        clickable={!!onLoggedInClick}
        key={key}
        variant='outlined'
        color={hasExpressed ? 'primary' : 'default'}
        onClick={onLoggedInClick ? e => {
          const currentTarget = e.currentTarget;
          if (this.props.loggedInUser) {
            onLoggedInClick && onLoggedInClick(currentTarget);
          } else {
            this.onLoggedIn = () => onLoggedInClick && onLoggedInClick(currentTarget);
            this.setState({ logInOpen: true });
          }
        } : undefined}
        classes={{
          label: this.props.classes.expressionInner,
          root: `${this.props.classes.expressionOuter} ${hasExpressed ? this.props.classes.expressionHasExpressed : this.props.classes.expressionNotExpressed}`,
        }}
        label={(
          <div style={{ display: 'flex', alignItems: 'center' }}>
            <span className={this.props.classes.expression}>{display}</span>
            {count > 0 && (<Typography variant='caption' color={hasExpressed ? 'primary' : undefined}>&nbsp;{count}</Typography>)}
          </div>
        )}
      />
    );
  }

  expressExpand(callback?: () => void) {
    this.setState({
      expressionExpanded: true,
    }, callback);
  }

  renderExpressionCount() {
    if (this.props.variant !== 'list'
      || this.props.display?.showExpression === false
      || !this.props.idea
      || !this.props.category?.support.express
      || (this.props.display?.showExpression === undefined && Object.keys(this.props.idea.expressions || {}).length === 0)
    ) return null;

    const [topEmoji, topEmojiCount] = Object.entries(this.props.idea.expressions || {})
      .reduce((l, r) => l[1] > r[1] ? l : r, ['', 0]);
    return (
      <Typography key='expressionTop' className={this.props.classes.itemCount} variant='caption'>
        {(!!topEmoji && !!topEmojiCount) ? (
          <span className={this.props.classes.expressionEmojiAsIcon}>
            {topEmoji}
          </span>
        ) : (
          <AddEmojiIcon fontSize='inherit' />
        )}
        &nbsp;
        {topEmojiCount || 0}
      </Typography>
    );
  }

  expressBarRef: React.RefObject<HTMLDivElement> = React.createRef();
  renderExpression() {
    if (this.props.variant === 'list'
      || this.props.display?.showExpression === false
      || !this.props.idea
      || !this.props.category?.support.express
    ) return null;
    const expressionAllowed: boolean = !this.props.idea.statusId
      || this.props.category.workflow.statuses.find(s => s.statusId === this.props.idea!.statusId)?.disableExpressions !== true;
    if (!expressionAllowed
      && (!this.props.idea.expressions || Object.keys(this.props.idea.expressions).length === 0)
      && !this.props.idea.expressionsValue) return null;

    const limitEmojiPerIdea = this.props.category.support.express.limitEmojiPerIdea;
    const reachedLimitPerIdea = limitEmojiPerIdea && (!!this.props.expression && Object.keys(this.props.expression).length || 0) > 0;

    const getHasExpressed = (display: string): boolean => {
      return this.props.expression
        && this.props.expression.includes(display)
        || false;
    };
    const clickExpression = (display: string) => {
      if (!expressionAllowed) return;
      var expressionDiff: Client.IdeaVoteUpdateExpressions | undefined = undefined;
      const hasExpressed = getHasExpressed(display);
      if (limitEmojiPerIdea) {
        if (hasExpressed) {
          expressionDiff = { action: Client.IdeaVoteUpdateExpressionsActionEnum.Unset, expression: display };
        } else {
          expressionDiff = { action: Client.IdeaVoteUpdateExpressionsActionEnum.Set, expression: display };
        }
      } else if (!hasExpressed && reachedLimitPerIdea) {
        this.props.enqueueSnackbar("Whoa, that's too many", { variant: 'warning', preventDuplicate: true });
        return;
      } else if (hasExpressed) {
        expressionDiff = { action: Client.IdeaVoteUpdateExpressionsActionEnum.Remove, expression: display };
      } else {
        expressionDiff = { action: Client.IdeaVoteUpdateExpressionsActionEnum.Add, expression: display };
      }
      this.updateVote({ expressions: expressionDiff });
      if (this.state.expressionExpanded
        && !!this.props.category?.support.express?.limitEmojiPerIdea) {
        this.setState({ expressionExpanded: false });
      }
    };

    const limitEmojiSet = this.props.category.support.express.limitEmojiSet
      ? new Set<string>(this.props.category.support.express.limitEmojiSet.map(e => e.display))
      : undefined;
    const unusedEmoji = new Set<string>(limitEmojiSet || []);
    const expressionsExpressed: React.ReactNode[] = [];
    this.props.idea.expressions && Object.entries(this.props.idea.expressions).forEach(([expression, count]) => {
      if (limitEmojiSet) {
        if (!limitEmojiSet.has(expression)) {
          return; // expression not in the list of approved expressions
        }
        unusedEmoji.delete(expression)
      };
      expressionsExpressed.push(this.renderExpressionEmoji(
        expression,
        expression,
        getHasExpressed(expression),
        expressionAllowed ? () => clickExpression(expression) : undefined,
        count));
    });
    const expressionsUnused: React.ReactNode[] = [...unusedEmoji].map(expressionDisplay =>
      this.renderExpressionEmoji(
        expressionDisplay,
        expressionDisplay,
        getHasExpressed(expressionDisplay),
        expressionAllowed ? () => clickExpression(expressionDisplay) : undefined,
        0));
    const picker = limitEmojiSet ? undefined : (
      <EmojiPicker
        key='picker'
        inline
        onSelect={emoji => clickExpression(((emoji as BaseEmoji).native) as never)}
      />
    );

    const maxItems = 30;
    const summaryItems: React.ReactNode[] = expressionsExpressed.length > 0 ? expressionsExpressed.slice(0, Math.min(maxItems, expressionsExpressed.length)) : [];

    const showMoreButton: boolean = !limitEmojiSet || summaryItems.length !== expressionsExpressed.length + expressionsUnused.length;

    return (
      <div key='renderExpression' style={{ display: 'flex' }}>
        <ClosablePopper
          anchorType='in-place'
          clickAway
          style={{
            width: limitEmojiSet ? 'max-content' : 'min-content',
          }}
          open={!!this.state.expressionExpanded}
          onClose={() => this.setState({ expressionExpanded: false })}
        >
          <div className={classNames(this.props.classes.expressionPopperPaper, this.props.classes.funding)}>
            {[
              ...expressionsExpressed,
              ...expressionsUnused,
            ]}
          </div>
          {picker}
        </ClosablePopper>
        <div
          key='expression'
          ref={this.expressBarRef}
          className={classNames(
            this.props.classes.funding,
            !!this.props.settings.demoFlashPostVotingControls && (this.state.demoFlashPostVotingControlsHovering === undefined ? this.props.classes.pulsateExpressions
              : (this.state.demoFlashPostVotingControlsHovering === 'express' ? this.props.classes.pulsateShown : this.props.classes.pulsateHidden)))}
          onMouseOver={!!this.props.settings.demoFlashPostVotingControls ? () => this.setState({ demoFlashPostVotingControlsHovering: 'express' }) : undefined}
          onMouseOut={!!this.props.settings.demoFlashPostVotingControls ? () => this.setState({ demoFlashPostVotingControlsHovering: undefined }) : undefined}
          style={{
            position: 'relative',
          }}
        >
          <GradientFade
            disabled={summaryItems.length < maxItems}
            start={'50%'}
            opacity={0.3}
            style={{
              display: 'flex',
            }}
          >
            {summaryItems}
          </GradientFade>
          {expressionAllowed && showMoreButton && this.renderExpressionEmoji(
            'showMoreButton',
            (
              <span className={this.props.classes.moreContainer}>
                <AddEmojiIcon fontSize='inherit' className={this.props.classes.moreMainIcon} />
                <AddIcon fontSize='inherit' className={this.props.classes.moreAddIcon} />
              </span>
            ),
            false,
            () => this.expressExpand(),
          )}
        </div>
      </div>
    );
  }

  renderCover() {
    const canEdit = this.canEdit() === 'mod' && this.props.variant === 'page';
    if (!this.props.category?.useCover
      || !this.props.idea
      || (!this.props.idea?.coverImg && !canEdit)) return null;
    return (
      <PostCover
        coverImg={this.props.idea?.coverImg}
        editable={canEdit ? img => (
          <PostCoverEdit
            server={this.props.server}
            content={img}
            onUploaded={coverImg => this.props.server.dispatchAdmin().then(d => d.ideaUpdateAdmin({
              projectId: this.props.projectId,
              ideaId: this.props.idea!.ideaId!,
              ideaUpdateAdmin: {
                coverImg,
              }
            }))}
          />
        ) : undefined}
      />
    );
  }

  renderTitle() {
    if (!this.props.idea?.title) return null;
    return (
      <PostTitle
        variant={this.props.variant}
        title={this.props.idea.title}
        titleTruncateLines={this.props.display?.titleTruncateLines}
        descriptionTruncateLines={this.props.display?.descriptionTruncateLines}
        demoBlurryShadow={this.props.settings.demoBlurryShadow}
        editable={this.canEdit() ? () => !!this.props.idea?.ideaId && (
          <PostEditTitleInline
            server={this.props.server}
            post={this.props.idea}
            bare
            TextFieldProps={{
              autoFocus: true,
            }}
          >
            {this.props.idea.title}
          </PostEditTitleInline>
        ) : undefined}
      />
    );
  }

  renderDescription() {
    if (!this.props.idea) return null;
    const idea = this.props.idea;
    return (
      <PostDescription
        variant={this.props.variant}
        description={idea.description}
        descriptionTruncateLines={this.props.display?.descriptionTruncateLines}
        demoBlurryShadow={this.props.settings.demoBlurryShadow}
        editable={this.canEdit() ? description => !!idea.ideaId && (
          <PostEditDescriptionInline
            server={this.props.server}
            post={idea}
            bare
            forceOutline
            noContentLabel={(
              <Typography className={this.props.classes.noContentLabel}
              >{this.props.t('add-description')}</Typography>
            )}
          >
            {description}
          </PostEditDescriptionInline>
        ) : undefined}
      />
    );
  }

  renderDisconnect() {
    if (!this.props.onDisconnect) return null;

    return (
      <React.Fragment key='disconnect'>
        <MyButton
          buttonVariant='post'
          Icon={this.props.disconnectType === 'merge' ? UnmergeIcon : UnLinkAltIcon}
          isSubmitting={this.props.isSubmittingDisconnect}
          onClick={e => this.props.onDisconnect?.()}
        >
          {this.props.disconnectType === 'link' ? this.props.t('unlink') : this.props.t('unmerge')}
        </MyButton>
      </React.Fragment>
    );
  }

  renderMerged() {
    if (!this.props.idea
      || this.props.variant === 'list'
      || !this.props.mergedToPost) return null;

    return (
      <div className={this.props.classes.links}>
        <ConnectedPostsContainer
          type='merge'
          direction='to'
          hasMultiple={false}
        >
          <ConnectedPost
            server={this.props.server}
            containerPost={this.props.idea!}
            post={this.props.mergedToPost}
            type='merge'
            direction='to'
            onClickPost={this.props.onClickPost}
            onUserClick={this.props.onUserClick}
          />
        </ConnectedPostsContainer>
      </div>
    );
  }

  renderLinkedToGitHub() {
    if (!this.props.idea
      || this.props.variant === 'list'
      || !this.props.idea.linkedGitHubUrl) return null;

    var content: React.ReactNode = this.props.idea.linkedGitHubUrl;
    // Expect form of "https://github.com/jenkinsci/jenkins/issues/100"
    const match = (new RegExp(/https:\/\/github.com\/([^/]+)\/([^/]+)\/issues\/([0-9])/))
      .exec(this.props.idea.linkedGitHubUrl);
    if (match) {
      const issueNumber = match[3];
      content = (
        <>
          Issue&nbsp;#{issueNumber}
        </>
      );
    }

    content = (
      <MuiLink
        href={this.props.idea.linkedGitHubUrl}
        target='_blank'
        rel='noopener nofollow'
        underline='none'
        color='textPrimary'
      >
        {content}
      </MuiLink>
    );

    return (
      <div className={this.props.classes.links}>
        <ConnectedPostsContainer
          type='github'
          direction='to'
          hasMultiple={false}
        >
          <OutlinePostContent>
            {content}
          </OutlinePostContent>
        </ConnectedPostsContainer>
      </div>
    );
  }

  renderLinks() {
    if (!this.props.idea
      || this.props.variant === 'list'
      || (!this.props.linkedToPosts?.length && !this.props.linkedFromPosts?.length)) return null;

    return (
      <div className={this.props.classes.links}>
        {(['to', 'from'] as LinkDirection[]).map(direction => {
          const posts = (direction === 'to' ? this.props.linkedToPosts : this.props.linkedFromPosts);
          if (!posts?.length) return null;
          return (
            <ConnectedPostsContainer
              type='link'
              direction={direction}
              hasMultiple={posts.length > 1}
            >
              {posts.map(post => (
                <ConnectedPost
                  key={post.ideaId}
                  server={this.props.server}
                  containerPost={this.props.idea?.ideaId ? this.props.idea : undefined}
                  post={post}
                  type='link'
                  direction={direction}
                  onClickPost={this.props.onClickPost}
                  onUserClick={this.props.onUserClick}
                />
              ))}
            </ConnectedPostsContainer>
          );
        })}
      </div>
    );
  }

  renderResponseAndStatus() {
    if (!this.props.idea) return null;

    var response;
    var status;
    var author;

    if (!this.state.showEditingStatusAndResponse) {
      response = this.renderResponse();
      status = this.props.variant !== 'list' && this.renderStatus(true);
      author = (this.props.idea.responseAuthorUserId && this.props.idea.responseAuthorName) ? {
        userId: this.props.idea.responseAuthorUserId,
        name: this.props.idea.responseAuthorName,
        isMod: true
      } : undefined;

      // Don't show if nothing to show OR if only status is present and author is unknown
      if (!response && (!status || !author)) return null;

    } else {
      response = this.renderResponse(true);
      status = this.renderStatus(true, true);
      author = this.props.loggedInUser;
    }

    return this.renderResponseAndStatusLayout(
      response,
      status,
      author,
      this.props.idea.responseEdited,
      !!this.state.showEditingStatusAndResponse,
    );
  }

  renderResponseAndStatusLayout(
    response: React.ReactNode,
    status: React.ReactNode,
    author?: React.ComponentProps<typeof UserWithAvatarDisplay>['user'],
    edited?: Date,
    isEditing?: boolean,
  ) {
    var content = (
      <div className={classNames(
        this.props.classes.responseContainer,
        this.props.variant === 'list' ? this.props.classes.responseContainerList : this.props.classes.responseContainerPage,
      )}>
        <div className={this.props.classes.responseHeader}>
          {this.props.variant !== 'list' && (
            <HelpPopper description='Pinned response'>
              <PinIcon color='inherit' fontSize='inherit' className={this.props.classes.pinIcon} />
            </HelpPopper>
          )}
          <UserWithAvatarDisplay
            onClick={this.props.onUserClick}
            user={author}
            baseline
          />
          {!!status && (
            <>
              <Typography variant='body1'>{this.props.t('changed-to')}&nbsp;</Typography>
              {status}
            </>
          )}
          {(!!edited && !isEditing) && (
            <Typography className={this.props.classes.timeAgo} variant='caption'>
              <TimeAgo date={edited} />
            </Typography>
          )}
        </div>
        {!!response && response}
      </div>
    );

    if (isEditing) {
      const changed = this.state.editingStatusId !== undefined || this.state.editingResponse !== undefined
      content = (
        <PostSaveButton
          open
          isSubmitting={this.state.isSubmittingStatusAndResponse}
          showNotify
          onCancel={() => this.setState({
            showEditingStatusAndResponse: false,
            editingResponse: undefined,
            editingStatusId: undefined,
          })}
          onSave={(doNotify) => {
            if (!this.props.idea?.ideaId || !changed) return;
            this.setState({ isSubmittingStatusAndResponse: true });
            postSave(
              this.props.server,
              this.props.idea.ideaId,
              {
                ...(this.state.editingStatusId !== undefined ? { statusId: this.state.editingStatusId } : {}),
                ...(this.state.editingResponse !== undefined ? { response: this.state.editingResponse } : {}),
                suppressNotifications: !doNotify,
              },
              () => this.setState({
                showEditingStatusAndResponse: false,
                editingResponse: undefined,
                editingStatusId: undefined,
                isSubmittingStatusAndResponse: false,
              }),
              () => this.setState({
                isSubmittingStatusAndResponse: false,
              }),
            );
          }}
        >
          {content}
        </PostSaveButton>
      );
    }

    return content;
  }

  renderResponse(isEditing?: boolean) {
    if (this.props.variant === 'list' && this.props.display && this.props.display.responseTruncateLines !== undefined && this.props.display.responseTruncateLines <= 0
      || !this.props.idea) return null;

    var content;
    if (!isEditing) {
      if (!this.props.idea.response) return null;
      content = (
        <RichViewer
          key={this.props.idea.response}
          iAgreeInputIsSanitized
          html={this.props.idea.response}
          toneDownHeadings={this.props.variant === 'list'}
        />
      );
    } else {
      content = (
        <PostEditResponse
          server={this.props.server}
          autoFocusAndSelect={this.state.showEditingStatusAndResponse === 'response'}
          value={this.state.editingResponse !== undefined
            ? this.state.editingResponse
            : this.props.idea.response}
          onChange={response => this.setState({ editingResponse: response })}
          isSubmitting={this.state.isSubmittingStatusAndResponse}
          RichEditorProps={{
            placeholder: this.props.t('add-a-response'),
          }}
          bare
          forceOutline
        />
      );
    }

    if (this.props.variant === 'list' && !isEditing) {
      content = (
        <TruncateFade variant='body1' lines={this.props.display?.responseTruncateLines}>
          <div>{content}</div>
        </TruncateFade>
      );
    }

    content = (
      <Typography variant='body1' component={'span'} className={`${this.props.classes.response} ${this.props.variant !== 'list' ? this.props.classes.responsePage : this.props.classes.responseList} ${this.props.settings.demoBlurryShadow ? this.props.classes.blurry : ''}`}>
        {content}
      </Typography>
    );

    if (this.props.variant !== 'list' && !isEditing && this.canEdit() === 'mod') {
      content = (
        <ClickToEdit isEditing={!!this.state.showEditingStatusAndResponse} setIsEditing={isEditing => this.setState({ showEditingStatusAndResponse: 'response' })} >
          {content}
        </ClickToEdit>
      );
    }

    return content;
  }

  renderTitleAndDescription(children: React.ReactNode, isOnlyPostOnClick: boolean) {
    if (this.props.variant !== 'list'
      || !this.props.expandable
      || !!this.props.onClickPost
      || !!this.props.disableOnClick
      || !this.props.idea
      || !!this.props.settings.demoDisablePostOpen) return (
        <div
          className={classNames(
            this.props.classes.titleAndDescription,
            this.props.onClickPost && !this.props.disableOnClick && this.props.classes.clickable,
          )}
          onClick={(this.props.onClickPost && !this.props.disableOnClick && !isOnlyPostOnClick) ? () => this.props.onClickPost && this.props.idea?.ideaId
            && this.props.onClickPost(this.props.idea.ideaId) : undefined}
        >
          {children}
        </div>
      );

    return (
      <Link
        className={classNames(this.props.classes.titleAndDescription, this.props.classes.clickable)}
        to={preserveEmbed(`/post/${this.props.idea.ideaId}`)}
      >
        {children}
      </Link>
    );
  }

  canEdit(): false | 'mod' | 'author' {
    if (this.props.variant === 'list') return false;
    if (this.props.server.isModOrAdminLoggedIn()) return 'mod';
    if (this.props.loggedInUser
      && this.props.idea?.authorUserId === this.props.loggedInUser.userId
    ) return 'author';
    return false;
  }

  async demoFundingAnimate(fundAmount: number) {
    if (!this.props.idea?.ideaId) return;

    const animate = animateWrapper(
      () => this._isMounted,
      this.inViewObserverRef,
      () => this.props.settings,
      this.setState.bind(this));

    if (await animate({ sleepInMs: 1000 })) return;
    const ideaId = this.props.idea.ideaId;
    const ideaWithVote: Client.IdeaWithVote = {
      ...this.props.idea!,
      vote: {
        vote: this.props.server.getStore().getState().votes.votesByIdeaId[ideaId],
        expression: this.props.server.getStore().getState().votes.expressionByIdeaId[ideaId],
        fundAmount: this.props.server.getStore().getState().votes.fundAmountByIdeaId[ideaId],
      },
    }
    const initialFundAmount = ideaWithVote.funded || 0;
    const targetFundAmount = initialFundAmount + fundAmount;
    var currFundAmount = initialFundAmount;
    var stepFundAmount = (fundAmount >= 0 ? 1 : -1);

    for (; ;) {
      if (await animate({ sleepInMs: 150 })) return;
      if (currFundAmount + stepFundAmount < Math.min(initialFundAmount, targetFundAmount)
        || currFundAmount + stepFundAmount > Math.max(initialFundAmount, targetFundAmount)) {
        stepFundAmount = -stepFundAmount;
        continue;
      }
      currFundAmount = currFundAmount + stepFundAmount;
      const msg: Client.ideaGetActionFulfilled = {
        type: Client.ideaGetActionStatus.Fulfilled,
        meta: {
          action: Client.Action.ideaGet,
          request: {
            projectId: this.props.projectId,
            ideaId: ideaId,
          },
        },
        payload: {
          ...ideaWithVote,
          funded: currFundAmount,
        },
      };
      // Private API just for this animation
      Server._dispatch(msg, this.props.server.store);
    }
  }

  async demoFundingControlAnimate(changes: Array<{ index: number; fundDiff: number; }>) {
    const animate = animateWrapper(
      () => this._isMounted,
      this.inViewObserverRef,
      () => this.props.settings,
      this.setState.bind(this));

    if (await animate({ sleepInMs: 1000 })) return;
    var isReverse = false;

    for (; ;) {
      if (await animate({ sleepInMs: 500 })) return;
      await new Promise<void>(resolve => this.fundingExpand(resolve));

      if (!this.fundingControlRef.current) return;
      await this.fundingControlRef.current.demoFundingControlAnimate(changes, isReverse);

      if (await animate({ setState: { fundingExpanded: false } })) return;

      isReverse = !isReverse;
    }
  }

  async demoVotingExpressionsAnimate(changes: Array<{
    type: 'vote';
    upvote: boolean;
  } | {
    type: 'express';
    update: Client.IdeaVoteUpdateExpressions;
  }>) {
    const animate = animateWrapper(
      () => this._isMounted,
      this.inViewObserverRef,
      () => this.props.settings,
      this.setState.bind(this));

    if (await animate({ sleepInMs: 500 })) return;
    for (; ;) {
      for (const change of changes) {
        if (await animate({ sleepInMs: 1000 })) return;
        switch (change.type) {
          case 'vote':
            if (change.upvote) {
              this.upvote();
            } else {
              this.downvote();
            }
            break;
          case 'express':
            await new Promise<void>(resolve => this.expressExpand(resolve));

            if (await animate({ sleepInMs: 1000 })) return;

            await this.updateVote({ expressions: change.update });

            if (await animate({ sleepInMs: 1000, setState: { expressionExpanded: false } })) return;

            break;
        }
      }
    }
  }

  async updateVote(ideaVoteUpdate: Client.IdeaVoteUpdate): Promise<Client.IdeaVoteUpdateResponse> {
    const dispatcher = await this.props.server.dispatch();
    const response = await dispatcher.ideaVoteUpdate({
      projectId: this.props.projectId,
      ideaId: this.props.idea!.ideaId!,
      ideaVoteUpdate,
    });
    return response;
  }
}
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: UserEdit.tsx    From clearflask with Apache License 2.0 4 votes vote down vote up
class UserEdit extends Component<Props & ConnectProps & WithTranslation<'app'> & WithMediaQuery & WithStyles<typeof styles, true> & WithSnackbarProps, State> {
  state: State = {};
  userAdminFetchedForUserId: string | undefined;

  render() {
    const userId = this.props.userId || this.state.createdUserId;

    const isMe = !!this.props.loggedInUser && this.props.loggedInUser.userId === userId;
    const isModOrAdminLoggedIn = this.props.server.isModOrAdminLoggedIn();

    var content;
    if (!userId) {
      // Create form
      if (!isModOrAdminLoggedIn) return null;
      content = (
        <div key='create-form' className={this.props.classes.section}>
          <PanelTitle text={this.props.t('create-user')} />
          <Grid container alignItems='center' className={this.props.classes.item}>
            <Grid item xs={12} sm={6}><Typography>{this.props.t('avatar')}</Typography></Grid>
            <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
              <AvatarDisplay user={{
                name: this.state.displayName || '',
              }} size={40} />
            </Grid>
          </Grid>
          <Grid container alignItems='center' className={this.props.classes.item}>
            <Grid item xs={12} sm={6}><Typography>{this.props.t('displayname')}</Typography></Grid>
            <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
              <TextField
                value={this.state.displayName || ''}
                onChange={e => this.setState({ displayName: e.target.value })}
              />
              <Button aria-label="Create" color='primary' style={{
                visibility:
                  !this.state.displayName ? 'hidden' : undefined
              }} onClick={async () => {
                if (!this.state.displayName || !isModOrAdminLoggedIn) {
                  return;
                }
                const newUserAdmin = await (await this.props.server.dispatchAdmin()).userCreateAdmin({
                  projectId: this.props.server.getProjectId(),
                  userCreateAdmin: { name: this.state.displayName },
                });
                this.setState({
                  createdUserId: newUserAdmin.userId,
                  userAdmin: newUserAdmin,
                  displayName: undefined,
                });
              }}>{this.props.t('save')}</Button>
            </Grid>
          </Grid>
        </div>
      );
    } else if (!isModOrAdminLoggedIn && !isMe) {
      // View only
      content = (
        <div key='view-only' className={this.props.classes.section}>
          <PanelTitle text={this.props.t('info')} />
          <Grid container alignItems='center' className={this.props.classes.item}>
            <Grid item xs={12} sm={6}><Typography>{this.props.t('avatar')}</Typography></Grid>
            <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
              <AvatarDisplay user={this.props.user} size={40} />
            </Grid>
          </Grid>
          <Grid container alignItems='center' className={this.props.classes.item}>
            <Grid item xs={12} sm={6}><Typography>{this.props.t('displayname')}</Typography></Grid>
            <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
              {DisplayUserName(this.props.user)}
            </Grid>
          </Grid>
          <Grid container alignItems='center' className={this.props.classes.item}>
            <Grid item xs={12} sm={6}><Typography>{this.props.t('registered')}</Typography></Grid>
            <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
              <TimeAgo date={this.props.user?.created || 0} />
            </Grid>
          </Grid>
        </div>
      );
    } else {
      // Edit form (for both self and by admin/mod)
      var user: Client.UserMe | Admin.UserAdmin | undefined;
      var balance: number | undefined;
      if (this.props.loggedInUser?.userId === userId) {
        user = this.props.loggedInUser;
        balance = this.props.loggedInUserBalance;
      } else {
        user = this.state.userAdmin;
        balance = this.state.userAdmin?.balance;
        if (this.userAdminFetchedForUserId !== userId) {
          this.userAdminFetchedForUserId = userId;
          this.props.server.dispatchAdmin().then(d => d.userGetAdmin({
            projectId: this.props.server.getProjectId(),
            userId,
          }))
            .then(userAdmin => this.setState({
              userAdmin,
              userAdminStatus: Status.FULFILLED,
            }))
            .catch(e => this.setState({
              userAdminStatus: Status.REJECTED,
            }));
        }
      }

      if (!user) {
        return (<LoadingPage />);
      }

      const balanceAdjustmentHasError = !!this.state.balanceAdjustment && (!parseInt(this.state.balanceAdjustment) || !+this.state.balanceAdjustment || parseInt(this.state.balanceAdjustment) !== parseFloat(this.state.balanceAdjustment));

      const browserPushControl = this.renderBrowserPushControl(isMe, user);
      // const androidPushControl = this.renderMobilePushControl(MobileNotificationDevice.Android);
      // const iosPushControl = this.renderMobilePushControl(MobileNotificationDevice.Ios);
      const emailControl = this.renderEmailControl(isMe, user);

      const isPushOrAnon = !user.email && !user.isExternal;

      const categoriesWithSubscribe = (this.props.categories || []).filter(c => !!c.subscription);

      content = (
        <React.Fragment key='edit-user'>
          <div className={this.props.classes.section}>
            <PanelTitle text={this.props.t('account')} />
            <Grid container alignItems='center' className={this.props.classes.item}>
              <Grid item xs={12} sm={6}><Typography>{this.props.t('avatar')}</Typography></Grid>
              <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                <AvatarDisplay user={{
                  ...user,
                  ...(this.state.displayName !== undefined ? {
                    name: this.state.displayName,
                  } : {}),
                  ...(this.state.email !== undefined ? {
                    email: this.state.email,
                  } : {}),
                }} size={40} />
              </Grid>
            </Grid>
            <Grid container alignItems='center' className={this.props.classes.item}>
              <Grid item xs={12} sm={6}><Typography>{this.props.t('displayname')}</Typography></Grid>
              <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                {!!user.isExternal ? (
                  <Tooltip title={this.props.t('cannot-be-changed')} placement='top-start'>
                    <Typography>{user.name || 'None'}</Typography>
                  </Tooltip>
                ) : (
                  <>
                    <TextField
                      id='displayName'
                      error={!user.name}
                      value={(this.state.displayName === undefined ? user.name : this.state.displayName) || ''}
                      onChange={e => this.setState({ displayName: e.target.value })}
                    />
                    <Button aria-label={this.props.t('save')} color='primary' style={{
                      visibility:
                        !this.state.displayName
                          || this.state.displayName === user.name
                          ? 'hidden' : undefined
                    }} onClick={async () => {
                      if (!this.state.displayName
                        || !user
                        || this.state.displayName === user.name) {
                        return;
                      }
                      if (isModOrAdminLoggedIn) {
                        const newUserAdmin = await (await this.props.server.dispatchAdmin()).userUpdateAdmin({
                          projectId: this.props.server.getProjectId(),
                          userId: userId!,
                          userUpdateAdmin: { name: this.state.displayName },
                        });
                        this.setState({ displayName: undefined, userAdmin: newUserAdmin });
                      } else {
                        await (await this.props.server.dispatch()).userUpdate({
                          projectId: this.props.server.getProjectId(),
                          userId: userId!,
                          userUpdate: { name: this.state.displayName },
                        });
                        this.setState({ displayName: undefined });
                      }
                    }}>{this.props.t('save')}</Button>
                  </>
                )}
              </Grid>
            </Grid>
            <Grid container alignItems='center' className={this.props.classes.item}>
              <Grid item xs={12} sm={6}><Typography>{this.props.t('email')}</Typography></Grid>
              <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                {!!user.isExternal ? (
                  <Tooltip title={this.props.t('cannot-be-changed')} placement='top-start'>
                    <Typography>{user.email || this.props.t('none')}</Typography>
                  </Tooltip>
                ) : (
                  <>
                    <TextField
                      id='email'
                      value={(this.state.email === undefined ? user.email : this.state.email) || ''}
                      onChange={e => this.setState({ email: e.target.value })}
                      autoFocus={!!this.state.createdUserId}
                    />
                    <Button aria-label={this.props.t('save')} color='primary' style={{
                      visibility:
                        !this.state.email
                          || this.state.email === user.email
                          ? 'hidden' : undefined
                    }} onClick={async () => {
                      if (!this.state.email
                        || !user
                        || this.state.email === user.email) {
                        return;
                      }
                      if (isModOrAdminLoggedIn) {
                        const newUserAdmin = await (await this.props.server.dispatchAdmin()).userUpdateAdmin({
                          projectId: this.props.server.getProjectId(),
                          userId: userId!,
                          userUpdateAdmin: { email: this.state.email },
                        });
                        this.setState({ email: undefined, userAdmin: newUserAdmin });
                      } else {
                        await (await this.props.server.dispatch()).userUpdate({
                          projectId: this.props.server.getProjectId(),
                          userId: userId!,
                          userUpdate: { email: this.state.email },
                        });
                        this.setState({ email: undefined });
                      }
                    }}>{this.props.t('save')}</Button>
                  </>
                )}
              </Grid>
            </Grid>
            {!user.isExternal && (
              <Grid container alignItems='center' className={this.props.classes.item}>
                <Grid item xs={12} sm={6}><Typography>{this.props.t('password-0')}</Typography></Grid>
                <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                  <TextField
                    id='password'
                    value={this.state.password || ''}
                    onChange={e => this.setState({ password: e.target.value })}
                    type={this.state.revealPassword ? 'text' : 'password'}
                    disabled={!this.state.email && !user.email}
                    InputProps={{
                      endAdornment: (
                        <InputAdornment position='end'>
                          <IconButton
                            aria-label='Toggle password visibility'
                            onClick={() => this.setState({ revealPassword: !this.state.revealPassword })}
                            disabled={!this.state.email && !user.email}
                          >
                            {this.state.revealPassword ? <VisibilityIcon fontSize='small' /> : <VisibilityOffIcon fontSize='small' />}
                          </IconButton>
                        </InputAdornment>
                      ),
                    }}
                  />
                  <Button aria-label={this.props.t('save')} color='primary' style={{
                    visibility:
                      !this.state.password
                        || this.state.password === user.name
                        ? 'hidden' : undefined
                  }} onClick={async () => {
                    if (!this.state.password
                      || !user) {
                      return;
                    }
                    if (isModOrAdminLoggedIn) {
                      const newUserAdmin = await (await this.props.server.dispatchAdmin()).userUpdateAdmin({
                        projectId: this.props.server.getProjectId(),
                        userId: userId!,
                        userUpdateAdmin: { password: this.state.password },
                      });
                      this.setState({ password: undefined, userAdmin: newUserAdmin });
                    } else {
                      await (await this.props.server.dispatch()).userUpdate({
                        projectId: this.props.server.getProjectId(),
                        userId: userId!,
                        userUpdate: { password: this.state.password },
                      });
                      this.setState({ password: undefined });
                    }
                  }}>{this.props.t('save')}</Button>
                </Grid>
              </Grid>
            )}
            {this.props.credits && (
              <Grid container alignItems='center' className={this.props.classes.item}>
                <Grid item xs={12} sm={6}><Typography>{this.props.t('balance')}</Typography></Grid>
                <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                  <CreditView val={balance || 0} credits={this.props.credits} />
                  {isMe && !!this.props.credits?.creditPurchase?.redirectUrl && (
                    <Button
                      component={'a' as any}
                      className={this.props.classes.linkGetMore}
                      color='primary'
                      href={this.props.credits.creditPurchase.redirectUrl}
                      target='_blank'
                      underline='none'
                      rel='noopener nofollow'
                    >
                      {this.props.credits.creditPurchase.buttonTitle || 'Get more'}
                    </Button>
                  )}
                  {isModOrAdminLoggedIn && (
                    <>
                      <IconButton onClick={() => this.setState({ transactionCreateOpen: !this.state.transactionCreateOpen })}>
                        <EditIcon />
                      </IconButton>
                      <Collapse in={this.state.transactionCreateOpen}>
                        <div>
                          <TextField
                            label='Adjustment amount'
                            value={this.state.balanceAdjustment || ''}
                            error={balanceAdjustmentHasError}
                            helperText={balanceAdjustmentHasError ? 'Invalid number' : (
                              !this.state.balanceAdjustment ? undefined : (
                                <CreditView
                                  val={+this.state.balanceAdjustment}
                                  credits={this.props.credits}
                                />
                              ))}
                            onChange={e => this.setState({ balanceAdjustment: e.target.value })}
                          />
                          <TextField
                            label='Transaction note'
                            value={this.state.balanceDescription || ''}
                            onChange={e => this.setState({ balanceDescription: e.target.value })}
                          />
                          <Button aria-label="Save" color='primary' style={{
                            visibility:
                              (this.state.balanceAdjustment || 0) === 0
                                ? 'hidden' : undefined
                          }} onClick={async () => {
                            if (this.state.balanceAdjustment === undefined
                              || +this.state.balanceAdjustment === 0
                              || !user) {
                              return;
                            }
                            const dispatcher = await this.props.server.dispatchAdmin();
                            const newUserAdmin = await dispatcher.userUpdateAdmin({
                              projectId: this.props.server.getProjectId(),
                              userId: userId!,
                              userUpdateAdmin: {
                                transactionCreate: {
                                  amount: +this.state.balanceAdjustment,
                                  summary: this.state.balanceDescription,
                                }
                              },
                            });
                            this.setState({
                              userAdmin: newUserAdmin,
                              transactionCreateOpen: false,
                              balanceAdjustment: undefined,
                              balanceDescription: undefined,
                            });
                          }}>Save</Button>
                        </div>
                      </Collapse>
                    </>
                  )}
                </Grid>
              </Grid>
            )}
            {isModOrAdminLoggedIn && (
              <>
                <Grid container alignItems='center' className={this.props.classes.item}>
                  <Grid item xs={12} sm={6}><Typography>{this.props.t('is-moderator')}</Typography></Grid>
                  <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                    <FormControlLabel
                      control={(
                        <Switch
                          color='default'
                          checked={!!user.isMod}
                          onChange={async (e, checked) => {
                            const dispatcher = await this.props.server.dispatchAdmin();
                            const newUserAdmin = await dispatcher.userUpdateAdmin({
                              projectId: this.props.server.getProjectId(),
                              userId: userId!,
                              userUpdateAdmin: { isMod: !user?.isMod },
                            });
                            this.setState({ password: undefined, userAdmin: newUserAdmin });
                          }}
                        />
                      )}
                      label={(
                        <FormHelperText component='span'>
                          {user.isMod ? this.props.t('yes') : this.props.t('no')}
                        </FormHelperText>
                      )}
                    />
                  </Grid>
                </Grid>
                <Grid container alignItems='center' className={this.props.classes.item}>
                  <Grid item xs={12} sm={6}><Typography>{this.props.t('user-id')}</Typography></Grid>
                  <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                    <Typography>{userId}</Typography>
                  </Grid>
                </Grid>
              </>
            )}
            {!!isMe && !this.props.suppressSignOut && (
              <Grid container alignItems='center' className={this.props.classes.item}>
                <Grid item xs={12} sm={6}><Typography>
                  {this.props.t('sign-out-of-your-account')}
                  {!!isPushOrAnon && (
                    <Collapse in={!!this.state.signoutWarnNoEmail}>
                      <Alert
                        variant='outlined'
                        severity='warning'
                      >
                        {this.props.t('please-add-an-email-before')}
                      </Alert>
                    </Collapse>
                  )}
                </Typography></Grid>
                <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                  <Button
                    disabled={!!isPushOrAnon && !!this.state.signoutWarnNoEmail}
                    onClick={() => {
                      if (isPushOrAnon) {
                        this.setState({ signoutWarnNoEmail: true });
                      } else {
                        this.props.server.dispatch().then(d => d.userLogout({
                          projectId: this.props.server.getProjectId(),
                        }));
                      }
                    }}
                  >Sign out</Button>
                </Grid>
              </Grid>
            )}
            <Grid container alignItems='center' className={this.props.classes.item}>
              <Grid item xs={12} sm={6}><Typography>{this.props.t('delete-account')}</Typography></Grid>
              <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                <Button
                  onClick={() => this.setState({ deleteDialogOpen: true })}
                >Delete</Button>
                <Dialog
                  open={!!this.state.deleteDialogOpen}
                  onClose={() => this.setState({ deleteDialogOpen: false })}
                >
                  <DialogTitle>Delete account?</DialogTitle>
                  <DialogContent>
                    <DialogContentText>{isMe
                      ? 'By deleting your account, you will be signed out of your account and your account will be permanently deleted including all of your data.'
                      : 'Are you sure you want to permanently delete this user?'}</DialogContentText>
                  </DialogContent>
                  <DialogActions>
                    <Button onClick={() => this.setState({ deleteDialogOpen: false })}>Cancel</Button>
                    <Button style={{ color: this.props.theme.palette.error.main }} onClick={async () => {
                      if (isModOrAdminLoggedIn) {
                        await (await this.props.server.dispatchAdmin()).userDeleteAdmin({
                          projectId: this.props.server.getProjectId(),
                          userId: userId!,
                        });
                      } else {
                        await (await this.props.server.dispatch()).userDelete({
                          projectId: this.props.server.getProjectId(),
                          userId: userId!,
                        });
                      }
                      this.props.onDeleted?.();
                      this.setState({ deleteDialogOpen: false });
                    }}>{this.props.t('delete')}</Button>
                  </DialogActions>
                </Dialog>
              </Grid>
            </Grid>
          </div>
          <div className={this.props.classes.section}>
            <PanelTitle text={this.props.t('notifications')} />
            {browserPushControl && (
              <Grid container alignItems='center' className={this.props.classes.item}>
                <Grid item xs={12} sm={6}><Typography>{this.props.t('browser-desktop-messages')}</Typography></Grid>
                <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>{browserPushControl}</Grid>
              </Grid>
            )}
            {/* {androidPushControl && (
              <Grid container alignItems='center' className={this.props.classes.item}>
                <Grid item xs={12} sm={6}><Typography>Android Push messages</Typography></Grid>
                <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>{androidPushControl}</Grid>
              </Grid>
            )}
            {iosPushControl && (
              <Grid container alignItems='center' className={this.props.classes.item}>
                <Grid item xs={12} sm={6}><Typography>Apple iOS Push messages</Typography></Grid>
                <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>{iosPushControl}</Grid>
              </Grid>
            )} */}
            {emailControl && (
              <Grid container alignItems='center' className={this.props.classes.item}>
                <Grid item xs={12} sm={6}>
                  <Typography>
                    {this.props.t('email')}
                    {user.email !== undefined && (<Typography variant='caption'>&nbsp;({truncateWithElipsis(20, user.email)})</Typography>)}
                  </Typography>
                </Grid>
                <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>{emailControl}</Grid>
              </Grid>
            )}
            {categoriesWithSubscribe.map(category => !!user && (
              <Grid container alignItems='center' className={this.props.classes.item}>
                <Grid item xs={12} sm={6}>
                  <Typography>{this.props.t('new-category', { category: category.name })}</Typography>
                </Grid>
                <Grid item xs={12} sm={6} className={this.props.classes.itemControls}>
                  {this.renderCategorySubscribeControl(category, isMe, user)}
                </Grid>
              </Grid>
            ))}
          </div>
        </React.Fragment>
      );
    }

    return (
      <div className={classNames(this.props.className, this.props.classes.settings)}>
        {content}
      </div>
    );
  }

  renderCategorySubscribeControl(category: Client.Category, isMe: boolean, user: Client.UserMe | Admin.UserAdmin) {
    if (!category.subscription) return null;

    const isSubscribed = user?.categorySubscriptions?.includes(category.categoryId);

    if (!isMe) {
      return user.browserPush ? this.props.t('subscribed') : this.props.t('not-subscribed');
    }

    return (
      <FormControlLabel
        control={(
          <Switch
            color='default'
            checked={!!isSubscribed}
            onChange={async (e, checked) => {
              const dispatcher = await this.props.server.dispatch();
              await dispatcher.categorySubscribe({
                projectId: this.props.server.getProjectId(),
                categoryId: category.categoryId,
                subscribe: !isSubscribed,
              });
            }}
          />
        )}
        label={(
          <FormHelperText component='span'>
            {isSubscribed ? this.props.t('subscribed') : this.props.t('not-subscribed')}
          </FormHelperText>
        )}
      />
    );
  }

  renderBrowserPushControl(isMe: boolean, user: Client.UserMe | Admin.UserAdmin): React.ReactNode | null {
    if (!this.props.config || !user || (!this.props.config.users.onboarding.notificationMethods.browserPush && !user.browserPush)) {
      return null;
    }

    if (!isMe) {
      return user.browserPush ? this.props.t('receiving') : this.props.t('not-receiving');
    }

    const browserPushStatus = WebNotification.getInstance().getStatus();
    var browserPushEnabled = !!user.browserPush;
    var browserPushControlDisabled;
    var browserPushLabel;
    if (user.browserPush) {
      browserPushControlDisabled = false;
      browserPushLabel = this.props.t('enabled');
    } else {
      switch (browserPushStatus) {
        case WebNotificationStatus.Unsupported:
          browserPushControlDisabled = true;
          browserPushLabel = 'Not supported by your browser';
          break;
        case WebNotificationStatus.Denied:
          browserPushControlDisabled = true;
          browserPushLabel = 'You have declined access to notifications';
          break;
        default:
        case WebNotificationStatus.Available:
        case WebNotificationStatus.Granted:
          browserPushControlDisabled = false;
          browserPushLabel = this.props.t('disabled');
          break;
      }
    }

    return (
      <FormControlLabel
        control={(
          <Switch
            color='default'
            disabled={browserPushControlDisabled}
            checked={browserPushEnabled}
            onChange={(e, checked) => {
              if (checked) {
                WebNotification.getInstance().askPermission()
                  .then(r => {
                    if (r.type === 'success') {
                      this.props.server.dispatch().then(d => d.userUpdate({
                        projectId: this.props.server.getProjectId(),
                        userId: user.userId,
                        userUpdate: { browserPushToken: r.token },
                      }));
                    } else if (r.type === 'error') {
                      if (r.userFacingMsg) {
                        this.props.enqueueSnackbar(r.userFacingMsg || 'Failed to setup browser notifications', { variant: 'error', preventDuplicate: true });
                      }
                      this.forceUpdate();
                    }
                  });
              } else {
                this.props.server.dispatch().then(d => d.userUpdate({
                  projectId: this.props.server.getProjectId(),
                  userId: user.userId,
                  userUpdate: { browserPushToken: '' },
                }));
              }
            }}
          />
        )}
        label={<FormHelperText component='span' error={browserPushControlDisabled}>{browserPushLabel}</FormHelperText>}
      />
    );
  }

  // renderMobilePushControl(device: MobileNotificationDevice) {
  //   if (!this.props.config || !user || (!this.props.config.users.onboarding.notificationMethods.mobilePush && (
  //     (device === MobileNotificationDevice.Android && !user.androidPush)
  //     || (device === MobileNotificationDevice.Ios && !user.iosPush)
  //   ))) {
  //     return;
  //   }


  //   const mobilePushStatus = MobileNotification.getInstance().getStatus();
  //   var mobilePushEnabled = false;
  //   var mobilePushControlDisabled;
  //   var mobilePushLabel;
  //   if ((device === MobileNotificationDevice.Android && user.androidPush)
  //     || (device === MobileNotificationDevice.Ios && user.iosPush)) {
  //     mobilePushEnabled = true;
  //     mobilePushControlDisabled = false;
  //     mobilePushLabel = 'Enabled';
  //   } else if (MobileNotification.getInstance().getDevice() !== device) {
  //     mobilePushControlDisabled = true;
  //     mobilePushLabel = 'Not supported on current device';
  //   } else {
  //     switch (mobilePushStatus) {
  //       case MobileNotificationStatus.Disconnected:
  //         mobilePushControlDisabled = true;
  //         mobilePushLabel = 'Not supported on current device';
  //         break;
  //       case MobileNotificationStatus.Denied:
  //         mobilePushControlDisabled = true;
  //         mobilePushLabel = 'You have declined access to notifications';
  //         break;
  //       default:
  //       case MobileNotificationStatus.Available:
  //       case MobileNotificationStatus.Subscribed:
  //         mobilePushControlDisabled = false;
  //         mobilePushLabel = 'Supported by your browser';
  //         break;
  //     }
  //   }

  //   return (
  //     <FormControlLabel
  //       control={(
  //         <Switch
  //           color='default'
  //           disabled={mobilePushControlDisabled}
  //           checked={mobilePushEnabled}
  //           onChange={(e, checked) => {
  //             if (checked) {
  //               WebNotification.getInstance().askPermission()
  //                 .then(r => {
  //                   if (r.type === 'success') {
  //                     this.props.server.dispatch().userUpdate({
  //                       projectId: this.props.server.getProjectId(),
  //                       userId: userId!,
  //                       userUpdate: device === MobileNotificationDevice.Android
  //                         ? { androidPushToken: r.token }
  //                         : { iosPushToken: r.token },
  //                     });
  //                   } else if (r.type === 'error') {
  //                     if (r.userFacingMsg) {
  //                       this.props.enqueueSnackbar(r.userFacingMsg || 'Failed to setup mobile notifications', { variant: 'error', preventDuplicate: true });
  //                     }
  //                     this.forceUpdate();
  //                   }
  //                 });
  //             } else {
  //               this.props.server.dispatch().userUpdate({
  //                 projectId: this.props.server.getProjectId(),
  //                 userId: userId!,
  //                 userUpdate: device === MobileNotificationDevice.Android
  //                   ? { androidPushToken: '' }
  //                   : { iosPushToken: '' },
  //               });
  //             }
  //           }}
  //         />
  //       )}
  //       label={<FormHelperText component='span' error={mobilePushControlDisabled}>{mobilePushLabel}</FormHelperText>}
  //     />
  //   );
  // }

  renderEmailControl(isMe: boolean, user: Client.UserMe | Admin.UserAdmin) {
    if (!this.props.config || !user || (!this.props.config.users.onboarding.notificationMethods.email && !user.email)) {
      return;
    }

    if (!isMe) {
      return user.browserPush ? this.props.t('receiving') : this.props.t('not-receiving');
    }

    var enabled;
    var controlDisabled;
    var label;
    if (user.email) {
      controlDisabled = false;
      enabled = user.emailNotify;
      if (user.emailNotify) {
        label = this.props.t('enabled');
      } else {
        label = this.props.t('disabled');
      }
    } else {
      controlDisabled = true;
      enabled = false;
      label = 'No email on account';
    }

    return (
      <FormControlLabel
        control={(
          <Switch
            color='default'
            disabled={controlDisabled}
            checked={enabled}
            onChange={async (e, checked) => {
              this.props.server.dispatch().then(d => d.userUpdate({
                projectId: this.props.server.getProjectId(),
                userId: user.userId,
                userUpdate: { emailNotify: checked },
              }));
            }}
          />
        )}
        label={<FormHelperText component='span' error={controlDisabled}>{label}</FormHelperText>}
      />
    );
  }
}
Example #9
Source File: RichEditorInternal.tsx    From clearflask with Apache License 2.0 4 votes vote down vote up
class RichEditorQuill extends React.Component<PropsQuill & Omit<InputProps, 'onChange'> & WithStyles<typeof styles, true> & WithSnackbarProps, StateQuill> implements PropsInputRef {
  readonly editorContainerRef: React.RefObject<HTMLDivElement> = React.createRef();
  readonly editorRef: React.RefObject<ReactQuill> = React.createRef();
  readonly dropzoneRef: React.RefObject<DropzoneRef> = React.createRef();
  inputIsFocused: boolean = false;
  isFocused: boolean = false;
  handleFocusBlurDebounced: () => void;

  constructor(props) {
    super(props);

    this.state = {
      showFormats: !!props.showControlsImmediately,
      showFormatsExtended: !!props.showControlsImmediately,
    };

    this.handleFocusBlurDebounced = debounce(() => this.handleFocusBlur(), 100);
  }

  /**
   * Focus and Blur events are tricky in Quill.
   * 
   * See:
   * - https://github.com/quilljs/quill/issues/1680
   * - https://github.com/zenoamaro/react-quill/issues/276
   * 
   * The solution attempts to solve these things:
   * - Focus detection using Quill editor selection AND input selection
   * - Spurious blur/focus flapping during clipboard paste
   * - Spurious blur/focus flapping when link popper open and click into quill editor but not textarea directly
   * - Keep focused during link popper changing
   * 
   * Outstanding issues:
   * - On clipboard paste, editor intermittently loses focus. This is mitigated with a debounce,
   *   but issue still occurrs occassionally if you continuously paste.
   */
  handleFocusBlur() {
    const editor = this.editorRef.current?.getEditor();
    if (!editor) return;
    const inputHasFocus = !!this.inputIsFocused;
    const editorHasSelection = !!editor.getSelection();
    const linkChangeHasFocus = !!this.state.editLinkShow;
    const hasFocus = inputHasFocus || editorHasSelection || linkChangeHasFocus;
    if (!this.isFocused && hasFocus) {
      this.isFocused = true;
      if (!this.state.showFormats) {
        this.setState({ showFormats: true });
      }
      this.props.onFocus?.({
        editor,
        stopPropagation: () => { },
      });
    } else if (this.isFocused && !hasFocus) {
      this.isFocused = false;
      this.props.onBlur?.({
        editor,
        stopPropagation: () => { },
      });
    }
  }

  focus(): void {
    this.editorRef.current?.focus();
  }

  blur(): void {
    this.editorRef.current?.blur();
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    if (this.state.editLinkShow !== prevState.editLinkShow) {
      this.handleFocusBlurDebounced();
    }
  }

  componentDidMount() {
    const editor = this.editorRef.current!.getEditor();

    editor.root.addEventListener('focus', e => {
      this.inputIsFocused = true;
      this.handleFocusBlurDebounced();
    });
    editor.root.addEventListener('blur', e => {
      this.inputIsFocused = false;
      this.handleFocusBlurDebounced();
    });

    editor.on('editor-change', (type, range) => {
      if (type === 'selection-change') {
        this.updateFormats(editor, range);
        this.handleFocusBlurDebounced();
      }
    });
    editor.on('scroll-optimize' as any, () => {
      const range = editor.getSelection();
      this.updateFormats(editor, range || undefined);
    });
    if (this.props.autoFocus) {
      editor.focus();
    }
    if (this.props.autoFocusAndSelect) {
      editor.setSelection(0, editor.getLength(), 'api');
    }
  }
  counter = 0;
  render() {
    const { value, onChange, onFocus, onBlur, ...otherInputProps } = this.props;

    return (
      <Dropzone
        ref={this.dropzoneRef}
        maxSize={10 * 1024 * 1024}
        multiple
        noClick
        noKeyboard
        onDrop={(acceptedFiles, rejectedFiles, e) => {
          rejectedFiles.forEach(rejectedFile => {
            rejectedFile.errors.forEach(error => {
              this.props.enqueueSnackbar(
                `${rejectedFile.file.name}: ${error.message}`,
                { variant: 'error' });
            })
          })
          this.onDropFiles(acceptedFiles);
        }}
      >
        {({ getRootProps, getInputProps }) => (
          <div
            {...getRootProps()}
            className={this.props.classes.editorContainer}
            ref={this.editorContainerRef}
            onClick={e => this.editorRef.current?.focus()}
          >
            <input {...getInputProps()} />
            <ReactQuill
              {...otherInputProps as any}
              modules={{
                clipboard: {
                  /**
                   * Fixes issue with newlines multiplying
                   * NOTE: When upgrading to Quill V2, this property is deprecated!
                   * https://github.com/KillerCodeMonkey/ngx-quill/issues/357#issuecomment-578138062
                   */
                  matchVisual: false,
                },
                imageResize: {
                  modules: ['Resize', ToolbarExtended],
                  toolbarButtonSvgStyles: {},
                },
              }}
              className={classNames(!!this.props.hidePlaceholder && 'hidePlaceholder', this.props.classes.quill)}
              theme={'' /** core theme */}
              ref={this.editorRef}
              value={value}
              onChange={(valueNew, delta, source, editor) => {
                this.props.onChange && this.props.onChange({
                  target: {
                    value: this.isQuillEmpty(editor) ? '' : valueNew,
                    delta,
                    source,
                    editor,
                  }
                });
                if (delta.ops && source === 'user') {
                  var retainCount = 0;
                  for (const op of delta.ops) {
                    if (op.insert
                      && typeof op.insert === 'object'
                      && op.insert.image
                      && typeof op.insert.image === 'string'
                      && op.insert.image.startsWith('data:')) {
                      this.onDataImageFound(op.insert.image, retainCount - 1);
                    }
                    retainCount += (op.insert ? 1 : 0) + (op.retain || 0) - (op.delete || 0);
                  }
                }
              }}
              formats={[
                'bold',
                'strike',
                'list',
                'link',
                'italic',
                'underline',
                'blockquote',
                'code-block',
                'indent',
                'header',
                'image', 'width', 'align',
              ]}
            />
            <Collapse mountOnEnter in={!!this.state.showFormats} className={this.props.classes.toggleButtonGroups}>
              <div className={this.props.classes.toggleButtonGroup}>
                {this.renderToggleButton(BoldIcon, 'bold', undefined, true)}
                {this.renderToggleButton(ItalicIcon, 'italic', undefined, true)}
                {this.renderToggleButton(UnderlineIcon, 'underline', undefined, true)}
                {this.renderToggleButton(StrikethroughIcon, 'strike', undefined, true)}
                {this.renderToggleButtonLink(LinkIcon)}
                {this.renderToggleButtonImage(ImageIcon)}
                {this.renderToggleButton(CodeIcon, undefined, 'code-block', true)}
                <Fade in={!this.state.showFormatsExtended}>
                  {this.renderToggleButtonCmpt(MoreIcon, false, () => this.setState({ showFormatsExtended: true }))}
                </Fade>
              </div>
              <Collapse mountOnEnter in={!!this.state.showFormatsExtended}>
                <div className={this.props.classes.toggleButtonGroup}>
                  {this.renderToggleButton(Heading2Icon, undefined, 'header', 2)}
                  {this.renderToggleButton(Heading3Icon, undefined, 'header', 3)}
                  {this.renderToggleButton(Heading4Icon, undefined, 'header', 4)}
                  {this.renderToggleButton(ListCheckIcon, undefined, 'list', 'unchecked', ['unchecked', 'checked'])}
                  {this.renderToggleButton(ListUnorderedIcon, undefined, 'list', 'bullet')}
                  {this.renderToggleButton(ListOrderedIcon, undefined, 'list', 'ordered')}
                  {this.renderToggleButton(QuoteIcon, undefined, 'blockquote', true)}
                </div>
              </Collapse>
            </Collapse>
            {this.renderEditLinkPopper()}
          </div>
        )}
      </Dropzone>
    );
  }

  renderEditLinkPopper() {
    const editor = this.editorRef.current?.getEditor();
    var anchorElGetter: AnchorBoundsGetter | undefined;
    if (this.state.editLinkShow && editor) {
      anchorElGetter = () => {
        const editorRect = this.editorContainerRef.current!.getBoundingClientRect();
        const selection = editor.getSelection();
        if (!selection) {
          return;
        }
        const bounds = { ...editor.getBounds(selection.index, selection.length) };
        return {
          height: bounds.height,
          width: bounds.width,
          bottom: editorRect.bottom - editorRect.height + bounds.bottom,
          left: editorRect.left + bounds.left,
          right: editorRect.right - editorRect.width + bounds.right,
          top: editorRect.top + bounds.top,
        }
      }
    }
    return (
      <ClosablePopper
        anchorType='virtual'
        anchor={anchorElGetter}
        zIndex={this.props.theme.zIndex.modal + 1}
        closeButtonPosition='disable'
        arrow
        clickAway
        clickAwayProps={{
          onClickAway: () => {
            if (this.editorRef.current?.editor?.hasFocus()) return;
            this.setState({
              editLinkShow: undefined,
            });
          }
        }}
        placement='top'
        open={!!this.state.editLinkShow}
        onClose={() => this.setState({
          editLinkShow: undefined,
        })}
        className={this.props.classes.editLinkContainer}
        classes={{
          paper: this.props.classes.editLinkContent,
        }}
      >
        {(!this.state.editLinkPrevValue || this.state.editLinkEditing) ? (
          <>
            <div>Enter link:</div>
            <TextField
              autoFocus
              variant='standard'
              size='small'
              margin='none'
              placeholder='https://'
              error={this.state.editLinkError}
              value={(this.state.editLinkValue === undefined
                ? this.state.editLinkPrevValue
                : this.state.editLinkValue) || ''}
              onChange={e => this.setState({
                editLinkValue: e.target.value,
                editLinkError: undefined,
              })}
              InputProps={{
                classes: {
                  input: this.props.classes.editLinkUrlInput,
                },
              }}
              classes={{
                root: this.props.classes.editLinkUrlRoot,
              }}
            />
            <Button
              size='small'
              color='primary'
              classes={{
                root: this.props.classes.editLinkButton,
                label: this.props.classes.editLinkButtonLabel
              }}
              disabled={!this.state.editLinkValue || this.state.editLinkValue === this.state.editLinkPrevValue}
              onClick={e => {
                if (!editor || !this.state.editLinkShow || !this.state.editLinkValue) return;
                const url = sanitize(this.state.editLinkValue);
                if (!url) {
                  this.setState({ editLinkError: true });
                  return;
                }
                if (this.state.editLinkShow.length > 0) {
                  editor.formatText(this.state.editLinkShow, 'link', url, 'user');
                } else {
                  editor.format('link', url, 'user');
                }
                this.setState({
                  editLinkPrevValue: url,
                  editLinkEditing: undefined,
                  editLinkValue: undefined,
                  editLinkError: undefined,
                });
              }}
            >Save</Button>
          </>
        ) : (
          <>
            <div>Visit</div>
            <a
              href={this.state.editLinkPrevValue}
              className={this.props.classes.editLinkA}
              target="_blank"
              rel="noreferrer noopener ugc"
            >{this.state.editLinkPrevValue}</a>
            <Button
              size='small'
              color='primary'
              classes={{
                root: this.props.classes.editLinkButton,
                label: this.props.classes.editLinkButtonLabel
              }}
              onClick={e => {
                this.setState({
                  editLinkEditing: true,
                })
              }}
            >Edit</Button>
          </>
        )}
        {(!!this.state.editLinkPrevValue) && (
          <Button
            size='small'
            color='primary'
            classes={{
              root: this.props.classes.editLinkButton,
              label: this.props.classes.editLinkButtonLabel
            }}
            onClick={e => {
              if (!editor || !this.state.editLinkShow) return;
              const editLinkShow = this.state.editLinkShow;
              this.setState({
                editLinkShow: undefined,
              }, () => {
                const [link, offset] = (editor.scroll as any).descendant(QuillFormatLinkExtended, editLinkShow.index);
                if (link !== null) {
                  editor.formatText(editLinkShow.index - offset, link.length(), 'link', false, 'user');
                } else {
                  editor.formatText(editLinkShow, { link: false }, 'user');
                }
              });
            }}
          >Remove</Button>
        )}
      </ClosablePopper >
    );
  }

  renderToggleButtonLink(IconCmpt) {
    const isActive = !!this.state.activeFormats?.link;
    return this.renderToggleButtonCmpt(
      IconCmpt,
      isActive,
      e => {
        const editor = this.editorRef.current?.getEditor();
        const selection = editor?.getSelection(true);
        if (!editor || !selection) return;
        const range = selection.length > 0
          ? selection
          : this.getWordBoundary(editor, selection.index);
        editor.setSelection(range, 'user');
        this.setState({
          editLinkShow: range,
          editLinkPrevValue: this.state.activeFormats?.link,
          editLinkEditing: true,
          editLinkValue: undefined,
          editLinkError: undefined,
        });
      })
  }

  renderToggleButtonImage(IconCmpt) {
    return this.renderToggleButtonCmpt(
      IconCmpt,
      false,
      e => this.dropzoneRef.current?.open())
  }

  /**
   * Convert data url by uploading and replacing with remote url.
   * 
   * This catches any images drag-n-drop dropzone or uploaded using
   * the image upload button.
   */
  async onDropFiles(files: File[]) {
    const editor = this.editorRef.current?.getEditor();
    if (!editor) return;
    const range = editor.getSelection(true);
    for (const file of files) {
      try {
        const url = await this.props.uploadImage(file);
        editor.insertEmbed(range.index, 'image', url, 'user');
      } catch (e) {
        this.props.enqueueSnackbar(
          `${file.name}: ${e}`,
          { variant: 'error' });
      }
    }
  }

  /**
   * Convert data url by uploading and replacing with remote url.
   * 
   * This catches any images not handled by the dropzone.
   * Particularly if you paste an image into the editor.
   */
  async onDataImageFound(imageDataUrl, retainCount) {
    const editor = this.editorRef.current?.getEditor();
    if (!editor) return;

    const blob = dataImageToBlob(imageDataUrl);

    var imageLink;
    try {
      imageLink = await this.props.uploadImage(blob);
    } catch (e) {
      imageLink = false;
      this.props.enqueueSnackbar(
        `Failed image upload: ${e}`,
        { variant: 'error' });
    };

    // Ensure the image is still in the same spot
    // or find where it was moved to and adjust retainCount
    const isOpOurImage = o => (typeof o?.insert === 'object'
      && typeof o.insert.image === 'string'
      && o.insert.image.startsWith(imageDataUrl.slice(0, Math.min(50, imageDataUrl.length))));
    var op = editor.getContents(retainCount).ops?.[0];
    if (!op || !isOpOurImage(op)) {
      retainCount = -1;
      do {
        retainCount++;
        // There must be a better way to find the index without getting contents
        // for each character here, but:
        // - This is safer than parsing the Delta format
        // - This should be a rare case
        op = editor.getContents(retainCount, 1).ops?.[0];
        if (op === undefined) {
          // The image was deleted while uploaded
          return;
        }
      } while (!isOpOurImage(op));
    }

    if (imageLink) {
      editor.updateContents(new Delta()
        .retain(retainCount)
        .delete(1)
        .insert({ image: imageLink }, op.attributes),
        'silent');
    } else {
      editor.updateContents(new Delta()
        .retain(retainCount)
        .delete(1),
        'silent');
    }
  }

  updateFormats(editor: Quill, range?: RangeStatic) {
    if (!range) {
      if (!!this.state.editLinkShow && !this.state.editLinkEditing) {
        this.setState({
          activeFormats: undefined,
          editLinkShow: undefined,
        });
      } else {
        this.setState({ activeFormats: undefined });
      }
    } else {
      const newActiveFormats = editor.getFormat(range);
      const isLinkActive = !!newActiveFormats.link;
      if (isLinkActive !== !!this.state.editLinkShow) {
        var rangeWord: RangeStatic | undefined;
        const selection = editor.getSelection();
        if (!!selection) {
          rangeWord = selection.length > 0
            ? selection
            : this.getWordBoundary(editor, selection.index);
        }



        this.setState({
          activeFormats: newActiveFormats,
          ...((isLinkActive && !!rangeWord) ? {
            editLinkShow: rangeWord,
            editLinkPrevValue: newActiveFormats.link,
            editLinkEditing: undefined,
            editLinkValue: undefined,
            editLinkError: undefined,
          } : (!this.state.editLinkEditing ? {
            editLinkShow: undefined,
          } : {}))
        });
      } else {
        this.setState({ activeFormats: newActiveFormats });
      }
    }
  }

  renderToggleButton(IconCmpt, format: string | undefined, formatLine: string | undefined, defaultValue: any, valueOpts: any[] = [defaultValue]) {
    const isActiveFormat = !!this.state.activeFormats && !!format && valueOpts.includes(this.state.activeFormats[format]);
    const isActiveFormatLine = !!this.state.activeFormats && !!formatLine && valueOpts.includes(this.state.activeFormats[formatLine]);
    const toggle = e => {
      const editor = this.editorRef.current?.getEditor();
      if (!editor) return;
      const range = editor.getSelection(true);
      const hasSelection = !!range && range.length > 0;
      // Use inline formatting if we have selected text or if there is no line formatting
      if (format && (!formatLine || hasSelection)) {
        if (hasSelection || !range) {
          editor.format(format, isActiveFormat ? false : defaultValue, 'user');
        } else {
          const wordBoundaryRange = this.getWordBoundary(editor, range.index);
          if (wordBoundaryRange.length > 0) {
            editor.formatText(wordBoundaryRange, { [format]: isActiveFormat ? false : defaultValue }, 'user');
          } else {
            editor.format(format, isActiveFormat ? false : defaultValue, 'user');
          }
        }
      } else if (!!formatLine) {
        editor.format(formatLine, isActiveFormatLine ? false : defaultValue, 'user');
      }
    };
    return this.renderToggleButtonCmpt(
      IconCmpt,
      isActiveFormat || isActiveFormatLine,
      toggle);
  }

  renderToggleButtonCmpt(IconCmpt, isActive, onClick) {
    const onChange = e => {
      onClick && onClick(e);
      e.preventDefault();
    };
    return (
      <Button
        className={this.props.classes.toggleButton}
        value='check'
        color={isActive ? 'primary' : 'inherit'}
        style={{ color: isActive ? undefined : this.props.theme.palette.text.hint }}
        onMouseDown={e => e.preventDefault()}
        onChange={onChange}
        onClick={onChange}
      >
        <IconCmpt fontSize='inherit' />
      </Button>
    );
  }

  /** 
   * Empty if contains only whitespace.
   * https://github.com/quilljs/quill/issues/163#issuecomment-561341501
   */
  isQuillEmpty(editor: UnprivilegedEditor): boolean {
    if ((editor.getContents()['ops'] || []).length !== 1) {
      return false;
    }
    return editor.getText().trim().length === 0;
  }


  /**
   * Selects whole word if cursor is inside the word (not at the beginning or end)
   */
  getWordBoundary(editor: Quill, index: number): RangeStatic {
    const [line, offset] = editor.getLine(index);
    if (!line) {
      return { index, length: 0 };
    }
    const text = editor.getText(
      editor.getIndex(line),
      line.length());

    // First check we are surrounded by non-whitespace
    if (offset === 0 || (WhitespaceChars.indexOf(text[offset - 1]) > -1)
      || offset >= text.length || (WhitespaceChars.indexOf(text[offset]) > -1)) {
      return { index, length: 0 };
    }

    // Iterate to the left until we find the beginning of word or start of line
    var boundaryIndex = index - 1;
    for (var x = offset - 2; x >= 0; x--) {
      if (WhitespaceChars.indexOf(text[x]) > -1) {
        break;
      }
      boundaryIndex--;
    }

    // Iterate to the right until we find the end of word or end of line
    var boundaryLength = index + 1 - boundaryIndex;
    for (var y = offset + 1; y < text.length; y++) {
      if (WhitespaceChars.indexOf(text[y]) > -1) {
        break;
      }
      boundaryLength++;
    }

    return { index: boundaryIndex, length: boundaryLength };
  }
}
Example #10
Source File: DataSettings.tsx    From clearflask with Apache License 2.0 4 votes vote down vote up
class DataSettings extends Component<Props & ConnectProps & WithStyles<typeof styles, true> & WithSnackbarProps & RouteComponentProps, State> {
  state: State = {};
  unsubscribe?: () => void;

  componentDidMount() {
    this.unsubscribe = this.props.server.getStore().subscribe(() => this.forceUpdate());
  }

  componentWillUnmount() {
    this.unsubscribe && this.unsubscribe();
  }

  render() {
    const projectName = getProjectName(this.props.server.getStore().getState().conf.conf);
    const DropzoneIcon = this.state.importFile ? FileIcon : UploadIcon;
    return (
      <div>
        <Section title='Import data'
          description={(
            <>
              Import posts into this project from another provider by uploading a Comma-Separated Value (CSV) file and then choose a mapping of your columns.
              <p>
                <Button
                  onClick={e => downloadBlobFile('clearflask-import-template.csv',
                    new Blob([
                      new Uint8Array([0xEF, 0xBB, 0xBF]), // UTF-8 BOM
                      'Created,Title,Description\n'
                      + '2019-04-01 00:00,First post,This is my first post\n'
                      + '2021-10-05 00:00,Second post,This is my second post\n',
                    ], { type: 'text/csv' }))}
                >Download template</Button>
              </p>
            </>
          )}
          preview={(
            <>
              <Dropzone
                minSize={1}
                onDrop={async (acceptedFiles, rejectedFiles, e) => {
                  rejectedFiles.forEach(rejectedFile => {
                    rejectedFile.errors.forEach(error => {
                      this.props.enqueueSnackbar(
                        `${rejectedFile.file.name}: ${error.message}`,
                        { variant: 'error' });
                    })
                  })
                  if (acceptedFiles.length > 0) {
                    const acceptedFile = acceptedFiles[0];
                    if (!acceptedFile.name.toLowerCase().endsWith(".csv")) {
                      this.props.enqueueSnackbar(
                        `${acceptedFile.name}: File type must be of type .csv`,
                        { variant: 'error' });
                      return;
                    }

                    var preview: string[][] | undefined;
                    try {
                      preview = await csvPreviewLines(acceptedFile, PreviewLines);
                    } catch (e) {
                      this.props.enqueueSnackbar(
                        `${acceptedFile.name}: Failed to parse`,
                        { variant: 'error' });
                      return;
                    }

                    if (preview.length === 0
                      || (preview.length === 1 && preview[0].length === 0)
                      || (preview.length === 1 && preview[0].length === 1 && preview[0][0] === '')) {
                      this.props.enqueueSnackbar(
                        `${acceptedFile.name}: File is empty`,
                        { variant: 'error' });
                      return;
                    }

                    this.setState({
                      importFirstRowIsHeader: preview.length > 1,
                      importFile: {
                        file: acceptedFile,
                        preview,
                      }
                    });
                  }
                }}
                disabled={this.state.importIsSubmitting}
              >
                {({ getRootProps, getInputProps }) => (
                  <div className={this.props.classes.dropzone} {...getRootProps()}>
                    <input {...getInputProps()} />
                    <DropzoneIcon color='inherit' className={this.props.classes.uploadIcon} />
                    {!this.state.importFile ? 'Import a CSV File' : this.state.importFile.file.name}
                  </div>
                )}
              </Dropzone>
              <Collapse in={!!this.state.importFile} className={this.props.classes.importCollapsed}>
                <div className={this.props.classes.importProperty}>
                  <FormControlLabel label='First line is a header' disabled={this.state.importIsSubmitting} control={(
                    <Checkbox size='small' color='primary' checked={!!this.state.importFirstRowIsHeader}
                      onChange={e => this.setState({ importFirstRowIsHeader: !this.state.importFirstRowIsHeader })}
                    />
                  )} />
                </div>
                <div className={this.props.classes.tableContainer}>
                  <Table size='small'>
                    <TableHead>
                      <TableRow>
                        {this.state.importFile && this.state.importFile.preview[0].map((header, index) => {
                          var selected: Label[] = [];
                          if (this.state.importIndexTitle === index) selected = [{ label: 'Title', value: 'title' }];
                          if (this.state.importIndexDescription === index) selected = [{ label: 'Description', value: 'description' }];
                          if (this.state.importIndexDateTime === index) selected = [{ label: 'Date/Time', value: 'dateTime' }];
                          if (this.state.importIndexStatusId === index) selected = [{ label: 'Status ID', value: 'statusId' }];
                          if (this.state.importIndexStatusName === index) selected = [{ label: 'Status Name', value: 'statusName' }];
                          if (this.state.importIndexTagIds === index) selected = [{ label: 'Tag IDs', value: 'tagIds' }];
                          if (this.state.importIndexTagNames === index) selected = [{ label: 'Tag Names', value: 'tagNames' }];
                          if (this.state.importIndexVoteValue === index) selected = [{ label: 'Vote Value', value: 'voteValue' }];
                          return (
                            <TableCell>
                              <SelectionPicker
                                className={this.props.classes.indexSelection}
                                disabled={this.state.importIsSubmitting}
                                showTags
                                bareTags
                                TextFieldProps={{
                                  size: 'small',
                                }}
                                placeholder={this.state.importFirstRowIsHeader ? header : undefined}
                                minWidth='150px'
                                disableInput
                                value={selected}
                                options={[
                                  { label: 'Title', value: 'importIndexTitle' },
                                  { label: 'Description', value: 'importIndexDescription' },
                                  { label: 'Date/Time', value: 'importIndexDateTime' },
                                  { label: 'Status ID', value: 'importIndexStatusId' },
                                  { label: 'Status Name', value: 'importIndexStatusName' },
                                  { label: 'Tag IDs', value: 'importIndexTagIds' },
                                  { label: 'Tag Names', value: 'importIndexTagNames' },
                                  { label: 'Vote Value', value: 'importIndexVoteValue' },
                                ]}
                                onValueChange={labels => {
                                  const stateUpdate = {};
                                  Object.entries(this.state).forEach(([prop, val]) => {
                                    if (prop.startsWith('importIndex') && val === index) {
                                      stateUpdate[prop] = undefined;
                                    }
                                  });
                                  const indexType = labels[0]?.value;
                                  if (indexType) stateUpdate[indexType] = index;
                                  this.setState(stateUpdate);
                                }}
                              />
                            </TableCell>
                          );
                        })}
                      </TableRow>
                    </TableHead>
                    <TableBody>
                      {this.state.importFile && this.state.importFile.preview.map((line, indexRow) => (indexRow + 1 !== PreviewLines) ? ((!!this.state.importFirstRowIsHeader && indexRow === 0) ? null : (
                        <TableRow key={`row-${indexRow}`}>
                          {line.map((cell, indexCol) => (
                            <TableCell key={`cell-${indexRow}-${indexCol}`}>{cell}</TableCell>
                          ))}
                        </TableRow>
                      )) : (
                        <TableRow key='row-more'>
                          <TableCell key='cell-more' colSpan={line.length} className={this.props.classes.omitBorder}>
                            ...
                          </TableCell>
                        </TableRow>
                      ))}
                    </TableBody>
                  </Table>
                </div>
                <Provider key={this.props.server.getProjectId()} store={this.props.server.getStore()}>
                  <div className={this.props.classes.importSelectionRow}>
                    <CategorySelectWithConnect
                      className={this.props.classes.importProperty}
                      width={180}
                      variant='outlined'
                      size='small'
                      label='Category'
                      errorText={!this.state.importCategoryId && 'Select category' || undefined}
                      value={this.state.importCategoryId || ''}
                      onChange={categoryId => this.setState({ importCategoryId: categoryId })}
                      disabled={this.state.importIsSubmitting}
                    />
                    <UserSelection
                      className={this.props.classes.importProperty}
                      width={180}
                      variant='outlined'
                      size='small'
                      server={this.props.server}
                      label='As user'
                      errorMsg='Select author'
                      disabled={this.state.importIsSubmitting}
                      onChange={selectedUserLabel => this.setState({ importAuthorUserId: selectedUserLabel?.value })}
                      allowCreate
                    />
                    <TextField
                      className={this.props.classes.importProperty}
                      style={{ width: 180 }}
                      variant='outlined'
                      size='small'
                      label='Timezone offset (min)'
                      helperText={this.state.tzOffInMin === undefined
                        ? Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.timeZone
                        : undefined}
                      value={this.state.tzOffInMin !== undefined ? this.state.tzOffInMin : getTimezoneOffsetInMin()}
                      onChange={e => this.setState({ tzOffInMin: parseInt(e.target.value) || 0 })}
                    />
                  </div>
                </Provider>
                <SubmitButton
                  disabled={this.state.importFile === undefined
                    || this.state.importCategoryId === undefined
                    || this.state.importAuthorUserId === undefined
                    || this.state.importIndexTitle === undefined}
                  isSubmitting={this.state.importIsSubmitting}
                  onClick={() => {
                    this.setState({ importIsSubmitting: true });
                    this.props.server.dispatchAdmin().then(d => d.projectImportPostAdmin({
                      projectId: this.props.server.getProjectId(),
                      firstRowIsHeader: this.state.importFirstRowIsHeader,
                      categoryId: this.state.importCategoryId!,
                      authorUserId: this.state.importAuthorUserId!,
                      indexTitle: this.state.importIndexTitle!,
                      indexDescription: this.state.importIndexDescription,
                      indexStatusId: this.state.importIndexStatusId,
                      indexStatusName: this.state.importIndexStatusName,
                      indexTagIds: this.state.importIndexTagIds,
                      indexTagNames: this.state.importIndexTagNames,
                      indexVoteValue: this.state.importIndexVoteValue,
                      indexDateTime: this.state.importIndexDateTime,
                      tzOffInMin: this.state.tzOffInMin,
                      body: this.state.importFile!.file,
                    })
                      .then(result => {
                        if (result.userFacingMessage) {
                          this.props.enqueueSnackbar(
                            result.userFacingMessage,
                            { variant: result.isError ? 'error' : 'success' });
                        }
                        if (result.isError) {
                          this.setState({ importIsSubmitting: false });
                        } else {
                          this.setState({
                            importIsSubmitting: undefined,
                            importFile: undefined,
                            importIndexTitle: undefined,
                            importIndexDescription: undefined,
                            importIndexStatusId: undefined,
                            importIndexStatusName: undefined,
                            importIndexTagIds: undefined,
                            importIndexTagNames: undefined,
                            importIndexVoteValue: undefined,
                            importIndexDateTime: undefined,
                            tzOffInMin: undefined,
                          });
                        }
                      })
                      .catch(e => this.setState({ importIsSubmitting: false })));
                  }}
                >Import</SubmitButton>
              </Collapse>
              <NeedHelpInviteTeammate server={this.props.server} />
            </>
          )}
        />
        <Section title='Export data'
          description="Export this project's data in a CSV format. Useful if you'd like to analyze your data yourself or move to another provider."
          preview={(
            <>
              <div className={this.props.classes.exportCheckboxes}>
                <FormControlLabel label='Posts' disabled={this.state.exportIsSubmitting} control={(
                  <Checkbox size='small' color='primary' checked={!!this.state.exportIncludePosts}
                    onChange={e => this.setState({ exportIncludePosts: !this.state.exportIncludePosts })}
                  />
                )} />
                <FormControlLabel label='Comments' disabled={this.state.exportIsSubmitting} control={(
                  <Checkbox size='small' color='primary' checked={!!this.state.exportIncludeComments}
                    onChange={e => this.setState({ exportIncludeComments: !this.state.exportIncludeComments })}
                  />
                )} />
                <FormControlLabel label='Users' disabled={this.state.exportIsSubmitting} control={(
                  <Checkbox size='small' color='primary' checked={!!this.state.exportIncludeUsers}
                    onChange={e => this.setState({ exportIncludeUsers: !this.state.exportIncludeUsers })}
                  />
                )} />
              </div>
              <SubmitButton
                disabled={!this.state.exportIncludeComments
                  && !this.state.exportIncludePosts
                  && !this.state.exportIncludeUsers}
                isSubmitting={this.state.exportIsSubmitting}
                onClick={() => {
                  this.setState({ exportIsSubmitting: true });
                  this.props.server.dispatchAdmin().then(d => d.projectExportAdmin({
                    projectId: this.props.server.getProjectId(),
                    includePosts: this.state.exportIncludePosts,
                    includeComments: this.state.exportIncludeComments,
                    includeUsers: this.state.exportIncludeUsers,
                  })
                    .then(fileDownload => {
                      this.setState({ exportIsSubmitting: false })
                      download(fileDownload.blob, fileDownload.filename, fileDownload.contentType);
                    })
                    .catch(e => this.setState({ exportIsSubmitting: false })));
                }}
              >Export</SubmitButton>
            </>
          )}
        />
        <Section title='Delete Project'
          description={(
            <>
              Permanently deletes {projectName}, settings, users, and all content.
            </>
          )}
          content={(
            <>
              <Button
                disabled={this.state.deleteIsSubmitting}
                style={{ color: !this.state.deleteIsSubmitting ? this.props.theme.palette.error.main : undefined }}
                onClick={() => this.setState({ deleteDialogOpen: true })}
              >Delete</Button>
              <Dialog
                open={!!this.state.deleteDialogOpen}
                onClose={() => this.setState({ deleteDialogOpen: false })}
              >
                <DialogTitle>Delete project</DialogTitle>
                <DialogContent>
                  <DialogContentText>Are you sure you want to permanently delete {projectName} including all content?</DialogContentText>
                </DialogContent>
                <DialogActions>
                  <Button onClick={() => this.setState({ deleteDialogOpen: false })}>Cancel</Button>
                  <SubmitButton
                    isSubmitting={this.state.deleteIsSubmitting}
                    style={{ color: !this.state.deleteIsSubmitting ? this.props.theme.palette.error.main : undefined }}
                    onClick={() => {
                      this.setState({ deleteIsSubmitting: true });
                      this.props.server.dispatchAdmin().then(d => d.projectDeleteAdmin({
                        projectId: this.props.server.getProjectId(),
                      }))
                        .then(() => {
                          ServerAdmin.get().removeProject(this.props.server.getProjectId());
                          this.setState({
                            deleteIsSubmitting: false,
                            deleteDialogOpen: false,
                          });
                          this.props.history.push('/dashboard');
                        })
                        .catch(e => this.setState({ deleteIsSubmitting: false }));
                    }}>Delete</SubmitButton>
                </DialogActions>
              </Dialog>
            </>
          )}
        />
      </div>
    );
  }
}
Example #11
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');
  }
}