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 |
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 |
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 |
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 |
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 |
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 |
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={(<> </>)}>
{leftSide}
</Delimited>
</div>
<div className={this.props.classes.grow} />
<div className={this.props.classes.bottomBarLine}>
<Delimited delimiter={(<> </>)}>
{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={(<> </>)}>
{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' />
{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}> {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' />
)}
{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 #{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')} </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 |
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 |
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'> ({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 |
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 |
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 |
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');
}
}