diff --git a/src/components/ACLEditor/ACLEditor.react.js b/src/components/ACLEditor/ACLEditor.react.js index 1f80cb156f..a1f46dc328 100644 --- a/src/components/ACLEditor/ACLEditor.react.js +++ b/src/components/ACLEditor/ACLEditor.react.js @@ -10,18 +10,58 @@ import PermissionsDialog from 'components/PermissionsDialog/PermissionsDialog.re import React from 'react'; function validateEntry(text) { - let userQuery = Parse.Query.or( - new Parse.Query(Parse.User).equalTo('username', text), - new Parse.Query(Parse.User).equalTo('objectId', text) - ); - let roleQuery = new Parse.Query(Parse.Role).equalTo('name', text); - return Promise.all([userQuery.find({ useMasterKey: true }), roleQuery.find({ useMasterKey: true })]).then(([user, role]) => { + + let userQuery; + let roleQuery; + + if (text === '*') { + return Promise.resolve({ entry: '*', type: 'public' }); + } + + if (text.startsWith('user:')) { + // no need to query roles + roleQuery = { + find: () => Promise.resolve([]) + }; + + let user = text.substring(5); + userQuery = new Parse.Query.or( + new Parse.Query(Parse.User).equalTo('username', user), + new Parse.Query(Parse.User).equalTo('objectId', user) + ); + } else if (text.startsWith('role:')) { + // no need to query users + userQuery = { + find: () => Promise.resolve([]) + }; + let role = text.substring(5); + roleQuery = new Parse.Query.or( + new Parse.Query(Parse.Role).equalTo('name', role), + new Parse.Query(Parse.Role).equalTo('objectId', role) + ); + } else { + // query both + userQuery = Parse.Query.or( + new Parse.Query(Parse.User).equalTo('username', text), + new Parse.Query(Parse.User).equalTo('objectId', text) + ); + + roleQuery = Parse.Query.or( + new Parse.Query(Parse.Role).equalTo('name', text), + new Parse.Query(Parse.Role).equalTo('objectId', text) + ); + } + + return Promise.all([ + userQuery.find({ useMasterKey: true }), + roleQuery.find({ useMasterKey: true }) + ]).then(([user, role]) => { if (user.length > 0) { - return { user: user[0] }; + return { entry: user[0], type: 'user' }; } else if (role.length > 0) { - return { role: role[0] }; + return { entry: role[0], type: 'role' }; } else { - throw new Error(); + return Promise.reject(); } }); } diff --git a/src/components/Autocomplete/Autocomplete.example.js b/src/components/Autocomplete/Autocomplete.example.js new file mode 100644 index 0000000000..7ecec5ab61 --- /dev/null +++ b/src/components/Autocomplete/Autocomplete.example.js @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React from 'react'; +import Autocomplete from 'components/Autocomplete/Autocomplete.react'; + +export const component = Autocomplete; + +class AutocompleteDemo extends React.Component { + constructor() { + super(); + + this.state = { + suggestions: ['aaa', 'abc', 'xxx', 'xyz'] + }; + + this.onSubmit = input => console.log('onSubmit: ' + input); + this.onUserInput = input => { + console.log(`input: ${input}`); + }; + this.buildLabel = input => + input.length > 0 + ? `You've typed ${input.length} characters` + : 'Start typing'; + this.buildSuggestions = input => + this.state.suggestions.filter(s => s.startsWith(input)); + } + + render() { + return ( + + ); + } +} + +export const demos = [ + { + render: () => ( +
+ +
+ ) + } +]; diff --git a/src/components/Autocomplete/Autocomplete.react.js b/src/components/Autocomplete/Autocomplete.react.js new file mode 100644 index 0000000000..ef9cb666fc --- /dev/null +++ b/src/components/Autocomplete/Autocomplete.react.js @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import Position from 'lib/Position'; +import PropTypes from 'prop-types' +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import styles from 'components/Autocomplete/Autocomplete.scss'; +import SuggestionsList from 'components/SuggestionsList/SuggestionsList.react'; + +export default class Autocomplete extends Component { + constructor(props) { + super(props); + + this.setHidden = this.setHidden.bind(this); + this.activate = this.activate.bind(this); + this.deactivate = this.deactivate.bind(this); + + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + + this.onClick = this.onClick.bind(this); + this.onChange = this.onChange.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onInputClick = this.onInputClick.bind(this); + this.onExternalClick = this.onExternalClick.bind(this); + + this.getPosition = this.getPosition.bind(this); + this.recalculatePosition = this.recalculatePosition.bind(this); + + this.inputRef = React.createRef(null); + this.dropdownRef = React.createRef(null); + + this.handleScroll = () => { + const pos = this.getPosition(); + this.dropdownRef.current.setPosition(pos); + }; + + this.handleResize = () => { + const pos = this.getPosition(); + this.dropdownRef.current.setPosition(pos); + }; + + this.state = { + activeSuggestion: 0, + filteredSuggestions: [], + showSuggestions: false, + userInput: '', + label: props.label, + position: null + }; + } + + componentDidMount() { + window.addEventListener('resize', this.handleResize); + this.node = ReactDOM.findDOMNode(this); + this.node.addEventListener('scroll', this.handleScroll); + this.recalculatePosition(); + this._ignoreBlur = false; + } + + componentWillUnmount() { + this.node.removeEventListener('scroll', this.handleScroll); + window.removeEventListener('resize', this.handleResize); + } + + getPosition() { + let newPosition = this.props.fixed + ? Position.inWindow(this.node) + : Position.inDocument(this.node); + + newPosition.y += this.node.offsetHeight; + + return newPosition; + } + + recalculatePosition() { + const position = this.getPosition(); + // update position of dropdown w/o rerendering this whole component + this.dropdownRef.current + ? this.dropdownRef.current.setPosition(position) + : this.setState({ position }, () => this.forceUpdate()); + } + + getSuggestions(userInput) { + const { suggestions, buildSuggestions } = this.props; + // either rely on external logic to recalculate suggestioons, + // or just filter by input + const filteredSuggestions = buildSuggestions + ? buildSuggestions(userInput) + : suggestions.filter( + suggestion => + suggestion.toLowerCase().indexOf(userInput.toLowerCase()) > -1 + ); + return filteredSuggestions; + } + + getLabel(userInput) { + return this.props.label || this.props.buildLabel(userInput); + } + + onChange(e) { + const userInput = e.currentTarget && e.currentTarget.value; + + const filteredSuggestions = this.getSuggestions(userInput); + const label = this.getLabel(userInput); + + this.setState({ + active: true, + activeSuggestion: 0, + filteredSuggestions, + showSuggestions: true, + userInput, + label, + error: undefined + }); + + this.props.onChange && this.props.onChange(userInput); + } + + onClick(e) { + const userInput = e.currentTarget.innerText; + const label = this.props.label || this.props.buildLabel(userInput); + + this.inputRef.current.focus(); + this._ignoreBlur = false; + + this.setState( + { + activeSuggestion: 0, + filteredSuggestions: [], + showSuggestions: false, + userInput, + label + }, + () => { + this.props.onClick && this.props.onClick(e); + } + ); + } + + onFocus(e) { + if (!this._ignoreBlur && !this.state.showSuggestions) { + this._ignoreBlur = true; + } + + this.activate(e); + } + + onBlur(e) { + this.props.onBlur && this.props.onBlur(e); + } + + onExternalClick(e) { + if (this._ignoreBlur) { + // because events flow in order: onFocus:input -> onClick:popover -> onClick:input + // need to ignore the click that initially focuses the input field + // otherwise it will hide the dropdown instantly. + // _ignoreBlur will be unset in input click handler right after. + return; + } + if (e.target.id !== this.inputRef.current.id) { + this.deactivate(); + } + } + + onInputClick() { + this._ignoreBlur = false; + } + + activate(e) { + const userInput = e.currentTarget && e.currentTarget.value; + + const position = this.getPosition(); + const filteredSuggestions = this.getSuggestions(userInput); + const label = this.getLabel(userInput); + + this.setState( + { + active: true, + filteredSuggestions, + position, + label, + showSuggestions: true + }, + () => { + this.props.onFocus && this.props.onFocus(); + } + ); + } + + deactivate() { + this.setState( + { + active: false, + showSuggestions: false, + activeSuggestion: 0 + }, + () => { + this.props.onBlur && this.props.onBlur(); + } + ); + } + + resetInput() { + this.setState( + { + active: false, + activeSuggestion: 0, + showSuggestions: false, + userInput: '' + }, + () => { + this.inputRef.current.blur(); + } + ); + } + + onKeyDown(e) { + const { activeSuggestion, filteredSuggestions } = this.state; + + // Enter + const { userInput } = this.state; + + if (e.keyCode === 13) { + if (userInput && userInput.length > 0) { + this.props.onSubmit(userInput); + } + } else if (e.keyCode === 9) { + // Tab + // do not type it + e.preventDefault(); + + e.stopPropagation(); + // move focus to input + this.inputRef.current.focus(); + this.setState({ + active: true, + activeSuggestion: 0, + showSuggestions: false, + userInput: filteredSuggestions[activeSuggestion] + }); + } else if (e.keyCode === 38) { + // arrow up + if (activeSuggestion === 0) { + return; + } + + this.setState({ + active: false, + activeSuggestion: activeSuggestion - 1 + }); + } else if (e.keyCode === 40) { + // arrow down + if (activeSuggestion - 1 === filteredSuggestions.length) { + return; + } + + this.setState({ + active: false, + activeSuggestion: activeSuggestion + 1 + }); + } + } + + setHidden(hidden) { + this.setState({ hidden }); + } + + render() { + const { + onExternalClick, + onInputClick, + onChange, + onClick, + onBlur, + onFocus, + onKeyDown, + props: { suggestionsStyle, inputStyle, placeholder, error }, + state: { + activeSuggestion, + filteredSuggestions, + showSuggestions, + userInput, + hidden, + active, + label + } + } = this; + + const fieldClassName = [ + styles.field, + active && styles.active, + error ? styles.error : undefined, + showSuggestions && + !hidden && + filteredSuggestions.length && + styles.dropdown + ].join(' '); + + const inputClasses = [error && styles.error].join(' '); + + let suggestionsListComponent; + if (showSuggestions && !hidden && filteredSuggestions.length) { + suggestionsListComponent = ( + + ); + } + + return ( + +
+ + +
+ {suggestionsListComponent} +
+ ); + } +} + +Autocomplete.propTypes = { + inputStyle: PropTypes.object.describe( + 'Styling for the input.' + ), + suggestionsStyle: PropTypes.object.describe( + 'Styling for the suggestions dropdown.' + ), + onChange: PropTypes.func.describe( + 'Callback triggered when input fiield is changed' + ), + onSubmit: PropTypes.func.describe( + 'Callback triggered when "enter" key pressed' + ), + placeholder: PropTypes.string.describe( + 'Placeholder text' + ), + buildSuggestions: PropTypes.func.describe( + 'Function receiving current input as an argument and should return an array to be rendered as suggestions' + ), + buildLabel: PropTypes.func.describe( + 'Function receiving current input as an argument and should return a string to be rendered as label' + ), + error: PropTypes.string.describe( + 'Error to be rendered in place of label if defined' + ) +} diff --git a/src/components/Autocomplete/Autocomplete.scss b/src/components/Autocomplete/Autocomplete.scss new file mode 100644 index 0000000000..a2e2d3ad60 --- /dev/null +++ b/src/components/Autocomplete/Autocomplete.scss @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ + @import "stylesheets/globals.scss"; + +.field { + width: 100%; + height: 56px; + border-radius: 4px; + position: relative; + transition: 0.3s background-color ease-in-out, 0.3s box-shadow ease-in-out; +} + +.field.dropdown input{ + border-radius: 5px 5px 0px 0px; +} + +.field.active input + label { + opacity: 1; + font-size: 0.5em; + color: #0e69a1; +} + +.field.locked { + pointer-events: none; +} + +.field input { + height: 40px; + + border: 1px solid $mainTextColor; + border-radius: 5px; + font-size: 14px; + outline: none; + vertical-align: top; + line-height: normal; + position: relative; + font-weight: 400; + transition: 0.3s background-color ease-in-out, 0.3s box-shadow ease-in-out, + 0.1s padding ease-in-out; + -webkit-appearance: none; +} + +.field input + label { + position: absolute; + top: 6px; + left: 26px; + font-family: "Gotham SSm A", "Gotham SSm B", sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 24px; + opacity: 0; + pointer-events: none; + transition: 0.1s all ease-in-out; +} + +.field input + label.error { + color: #ec392f; +} \ No newline at end of file diff --git a/src/components/Chip/Chip.example.js b/src/components/Chip/Chip.example.js new file mode 100644 index 0000000000..1e3072a06f --- /dev/null +++ b/src/components/Chip/Chip.example.js @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React from 'react'; +import Chip from 'components/Chip/Chip.react'; + +export const component = Chip; + +export const demos = [ + { + render: () => ( +
+ + + +
+ ) + } +]; diff --git a/src/components/Chip/Chip.react.js b/src/components/Chip/Chip.react.js new file mode 100644 index 0000000000..678063337e --- /dev/null +++ b/src/components/Chip/Chip.react.js @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React from 'react'; +import styles from 'components/Chip/Chip.scss'; +import PropTypes from 'prop-types' +import Icon from 'components/Icon/Icon.react' + +let Chip = ({ value, onClose }) => ( +
+
{value}
+
{ + try{ + e.stopPropagation(); + e.nativeEvent.stopPropagation(); + } catch(e){ + console.error(e); + } + + onClose(value); + } + }> + + +
+
+); + +export default Chip; + + +Chip.propTypes = { + onClose: PropTypes.func.isRequired.describe( + 'A function called when the close button clicked. It receives the value of as the only parameter.' + ), + value: PropTypes.string.isRequired.describe( + 'The string to be rendered inside chip.' + ) +} diff --git a/src/components/Chip/Chip.scss b/src/components/Chip/Chip.scss new file mode 100644 index 0000000000..7d28e421a1 --- /dev/null +++ b/src/components/Chip/Chip.scss @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ + +.chip { + display: inline-flex; + flex-direction: row; + background-color: #d5e5f2; + border: none; + cursor: pointer; + height: 32px; + outline: none; + padding: 4px; + margin: 4px; + font-size: 14px; + color: #0e69a1; + font-family: "Open Sans", sans-serif; + white-space: nowrap; + align-items: center; + border-radius: 8px; + vertical-align: middle; + text-decoration: none; + justify-content: center; +} + +.content { + cursor: inherit; + display: flex; + align-items: center; + user-select: none; + white-space: nowrap; + padding-left: 12px; + padding-right: 12px; +} + +.chip { + svg { + color: #999999; + cursor: pointer; + height: auto; + margin: 20px 6px 0 -8px; + fill: currentColor; + width: 1em; + height: 1em; + display: inline-block; + font-size: 16px; + transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + user-select: none; + flex-shrink: 0; + } + svg:hover { + color: red; + } +} diff --git a/src/components/MultiSelect/MultiSelect.react.js b/src/components/MultiSelect/MultiSelect.react.js index 08532a641a..327b7d5303 100644 --- a/src/components/MultiSelect/MultiSelect.react.js +++ b/src/components/MultiSelect/MultiSelect.react.js @@ -13,6 +13,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import stringList from 'lib/stringList'; import styles from 'components/MultiSelect/MultiSelect.scss'; +import Chip from 'components/Chip/Chip.react'; export default class MultiSelect extends React.Component { constructor() { @@ -21,10 +22,23 @@ export default class MultiSelect extends React.Component { open: false, position: null } + this.popoverRef = React.createRef(null); + this.handleScroll = () => { + let newPosition = this.props.fixed ? Position.inWindow(this.node) : Position.inDocument(this.node); + newPosition.y += this.node.offsetHeight; + if(this.popoverRef.current){ + this.popoverRef.current.setPosition(newPosition); + } + } } componentDidMount() { this.node = ReactDOM.findDOMNode(this); + window.addEventListener('scroll', this.handleScroll) + + } + componentWillUnmount(){ + window.removeEventListener('scroll', this.handleScroll) } componentWillReceiveProps() { @@ -65,31 +79,34 @@ export default class MultiSelect extends React.Component { let popover = null; if (this.state.open) { let width = this.node.clientWidth; + + let classes = [styles.menu]; + if (this.props.dense){ + classes.push(styles.dense); + } + popover = ( - -
+ +
{React.Children.map(this.props.children, c => React.cloneElement(c, { ...c.props, checked: this.props.value.indexOf(c.props.value) > -1, - onClick: this.select.bind(this, c.props.value) + onClick: c.props.disabled? null : this.select.bind(this, c.props.value) } ))}
) } - let content = []; + + let selection = []; let classes = [styles.current]; React.Children.forEach(this.props.children, c => { if (this.props.value.indexOf(c.props.value) > -1) { - content.push(c.props.children); + selection.push(c.props.children); } }); - if (content.length === 0 && this.props.placeHolder){ - content.push(this.props.placeHolder); - classes.push(styles.placeholder); - } let dropdownStyle = {}; if (this.props.width) { dropdownStyle = { @@ -97,10 +114,42 @@ export default class MultiSelect extends React.Component { float: 'left' }; } + + let dropDownClasses = [styles.dropdown]; + if (this.props.dense){ + dropDownClasses.push(styles.dense); + } + + let content = null; + + if (selection.length === 0 && this.props.placeHolder){ + content = this.props.placeHolder; + classes.push(styles.placeholder); + } else { + + content = this.props.chips? + selection.map((child,index) => { + let item; + if(Array.isArray(this.props.value)){ + item = this.props.value[index] + } + return ( + { + if(removed) this.select(removed); + }}> + {child} + )} + ) : + stringList(selection, this.props.endDelineator); + } + return ( -
+
- {stringList(content, this.props.endDelineator)} + {content}
{popover}
@@ -124,4 +173,10 @@ MultiSelect.propTypes = { endDelineator: PropTypes.string.describe( 'End delineator to separate last selected option.' ), + dense: PropTypes.bool.describe( + 'Mini variant - less height' + ), + chips: PropTypes.bool.describe( + 'Display chip for every selected item' + ), } diff --git a/src/components/MultiSelect/MultiSelect.scss b/src/components/MultiSelect/MultiSelect.scss index 8a8e464897..ded409926e 100644 --- a/src/components/MultiSelect/MultiSelect.scss +++ b/src/components/MultiSelect/MultiSelect.scss @@ -5,7 +5,7 @@ * This source code is licensed under the license found in the LICENSE file in * the root directory of this source tree. */ -@import 'stylesheets/globals.scss'; +@import "stylesheets/globals.scss"; .dropdown { position: relative; @@ -15,7 +15,8 @@ overflow: hidden; } -.dropdown, .menu { +.dropdown, +.menu { text-align: left; cursor: pointer; white-space: nowrap; @@ -51,7 +52,7 @@ text-overflow: ellipsis; &:hover { - background: rgba(0,0,0,0.1); + background: rgba(0, 0, 0, 0.1); .unchecked { background: white; @@ -59,7 +60,15 @@ } } -.checked, .unchecked { +.option.disabled { + &:hover { + background: white; + cursor: default; + } +} + +.checked, +.unchecked { position: absolute; width: 32px; height: 32px; @@ -81,6 +90,48 @@ } } -.placeholder { +.current.placeholder { color: $secondaryTextColor; + text-align: center; +} + +.dropdown.dense { + background: none; +} + +.dropdown.dense { + background: none; +} +.dense { + .checked, + .unchecked { + position: absolute; + width: 18px; + height: 18px; + border-radius: 100%; + top: 4px; + right: 4px; + } + .checked { + svg { + position: absolute; + top: 2px; + left: 2px; + font-size: small; + } + } + + .current { + text-align: center; + min-height: 50px; + height: 100%; + padding: 0px; + line-height: 48px; + } + + .option { + height: 30px; + line-height: 32px; + padding: 0 50px 0 14px; + } } diff --git a/src/components/MultiSelect/MultiSelectOption.react.js b/src/components/MultiSelect/MultiSelectOption.react.js index 95e90ce798..3e8bf8ad08 100644 --- a/src/components/MultiSelect/MultiSelectOption.react.js +++ b/src/components/MultiSelect/MultiSelectOption.react.js @@ -9,16 +9,32 @@ import Icon from 'components/Icon/Icon.react'; import React from 'react'; import styles from 'components/MultiSelect/MultiSelect.scss'; -let MultiSelectOption = ({ checked, children, ...other }) => ( -
+let MultiSelectOption = ({ checked, children, dense, disabled, ...other }) => { + + const classes = [styles.option, + disabled? styles.disabled: undefined + ]; + + const icon = checked ? ( +
+ +
+ ) : ( +
+ ) + + return ( + +
{children} - {checked ? -
- -
: -
- } + {disabled ?
); +} export default MultiSelectOption; diff --git a/src/components/PermissionsDialog/PermissionsDialog.example.js b/src/components/PermissionsDialog/PermissionsDialog.example.js index 5e43c279ae..5f21a96bdb 100644 --- a/src/components/PermissionsDialog/PermissionsDialog.example.js +++ b/src/components/PermissionsDialog/PermissionsDialog.example.js @@ -5,7 +5,7 @@ * This source code is licensed under the license found in the LICENSE file in * the root directory of this source tree. */ -import Parse from 'parse'; +// import Parse from 'parse'; import React from 'react'; import PermissionsDialog from 'components/PermissionsDialog/PermissionsDialog.react'; import Button from 'components/Button/Button.react'; @@ -13,34 +13,53 @@ import Button from 'components/Button/Button.react'; export const component = PermissionsDialog; function validateSimple(text) { - if (text.startsWith('i')) { - return Promise.resolve({ user: { id: text } }); + + if (text.startsWith('u')) { + return Promise.resolve({ entry: { id: text, get:() => 'demouser' } , type:'user'}); } - if (text.startsWith('r')) { - return Promise.resolve({ role: new Parse.Role(text, new Parse.ACL()) }); + if (text.startsWith('role:')) { + const roleName = text.substring(5); + return Promise.resolve({ entry: {id:`1d0f${roleName}`, getName:()=>roleName}, type:'role' }); } - if (text.startsWith('u')) { - return Promise.resolve({ user: { id: 'i' + ((Math.random() * 10000) | 0)}}); + if (text.startsWith('ptr')) { + return Promise.resolve({ entry: text, type: 'pointer' }); } return Promise.reject(); } function validateAdvanced(text) { - if (text.startsWith('i')) { - return Promise.resolve({ user: { id: text } }); - } - if (text.startsWith('r')) { - return Promise.resolve({ role: new Parse.Role(text, new Parse.ACL()) }); + if (text==='*') { + return Promise.resolve({ entry: '*' , type:'public'}); } if (text.startsWith('u')) { - return Promise.resolve({ user: { id: 'i' + ((Math.random() * 10000) | 0)}}); + return Promise.resolve({ entry: { id: text, get:() => 'demouser' } , type:'user'}); } - if (text.startsWith('p')) { - return Promise.resolve({ pointer: text }); + if (text.startsWith('role:')) { + const roleName = text.substring(5); + return Promise.resolve({ entry: {id:`1d0f${roleName}`, getName:()=>roleName}, type:'role' }); + } + if (text.startsWith('ptr')) { + return Promise.resolve({ entry: text, type: 'pointer' }); } return Promise.reject(); } +const columns = { + 'email': { type: 'String',}, + 'password':{ type: 'String', }, + 'ptr_owner':{ type: 'Pointer', targetClass:'_User'}, + 'nickname':{ type: 'String',}, + 'ptr_followers':{ type: 'Array', }, + 'ptr_friends':{ type: 'Array', } +}; + +const userPointers = [ + 'ptr_followers', + 'ptr_owner', + 'ptr_friends' +] + + class DialogDemo extends React.Component { constructor() { super() @@ -74,8 +93,8 @@ class DialogDemo extends React.Component { confirmText='Save ACL' details={Learn more about ACLs and app security} permissions={{ - read: {'*': true}, - write: {'*': true}, + read: {'*': true, 'role:admin': true, 'role:user': true, 'us3r1d':true}, + write: {'*': true, 'role:admin':true }, }} validateEntry={validateSimple} onCancel={() => { @@ -83,6 +102,7 @@ class DialogDemo extends React.Component { showSimple: false, }); }} + coolumns={columns} onConfirm={(perms) => { console.log(perms); }} /> : null} @@ -93,16 +113,19 @@ class DialogDemo extends React.Component { confirmText='Save CLP' details={Learn more about CLPs and app security} permissions={{ - get: {'*': false, '1234asdf': true, 'role:admin': true}, - find: {'*': true, '1234asdf': true, 'role:admin': true}, - create: {'*': true}, - update: {'*': true}, - delete: {'*': true}, - addField: {'*': true}, - readUserFields: ['owner'], - writeUserFields: ['owner'] + get: {'*': false, 'us3r1d': true, 'role:admin': true,}, + find: {'*': true, 'us3r1d': true, 'role:admin': true, }, + create: {'*': true, }, + update: {'*': true, pointerFields: ['user']}, + delete: {'*': true, }, + addField: {'*': true, 'requiresAuthentication': true}, + readUserFields: ['ptr_owner', 'ptr_followers', 'ptr_friends'], + writeUserFields: ['ptr_owner'], + protectedFields: {'*': ['password', 'email'], 'userField:ptr_owner': []} }} + columns={columns} validateEntry={validateAdvanced} + userPointers={userPointers} onCancel={() => { this.setState({ showAdvanced: false, diff --git a/src/components/PermissionsDialog/PermissionsDialog.react.js b/src/components/PermissionsDialog/PermissionsDialog.react.js index bd13051c42..440c864e57 100644 --- a/src/components/PermissionsDialog/PermissionsDialog.react.js +++ b/src/components/PermissionsDialog/PermissionsDialog.react.js @@ -5,257 +5,580 @@ * This source code is licensed under the license found in the LICENSE file in * the root directory of this source tree. */ -import Button from 'components/Button/Button.react'; -import Checkbox from 'components/Checkbox/Checkbox.react'; -import Icon from 'components/Icon/Icon.react'; -import { Map } from 'immutable'; -import Pill from 'components/Pill/Pill.react'; -import Popover from 'components/Popover/Popover.react'; -import Position from 'lib/Position'; -import React from 'react'; -import SliderWrap from 'components/SliderWrap/SliderWrap.react'; -import styles from 'components/PermissionsDialog/PermissionsDialog.scss'; -import Toggle from 'components/Toggle/Toggle.react'; import { - unselectable, - verticalCenter -} from 'stylesheets/base.scss'; + unselectable, + verticalCenter } from 'stylesheets/base.scss'; +import Button from 'components/Button/Button.react'; +import Checkbox from 'components/Checkbox/Checkbox.react'; +import Icon from 'components/Icon/Icon.react'; +import Pill from 'components/Pill/Pill.react'; +import Popover from 'components/Popover/Popover.react'; +import Position from 'lib/Position'; +import React from 'react'; +import ScrollHint from 'components/ScrollHint/ScrollHint.react' +import SliderWrap from 'components/SliderWrap/SliderWrap.react'; +import styles from 'components/PermissionsDialog/PermissionsDialog.scss'; +import Toggle from 'components/Toggle/Toggle.react'; +import Autocomplete from 'components/Autocomplete/Autocomplete.react'; +import { Map, fromJS } from 'immutable'; +import TrackVisibility from 'components/TrackVisibility/TrackVisibility.react'; let origin = new Position(0, 0); -function renderAdvancedCheckboxes(rowId, perms, advanced, onChange) { - const get = perms.get('get').get(rowId) || perms.get('get').get('*'); - const find = perms.get('find').get(rowId) || perms.get('find').get('*'); - const count = perms.get('count').get(rowId) || perms.get('count').get('*'); +function resolvePermission(perms, rowId, column) { + let isPublicRow = rowId === '*'; + let isAuthRow = rowId === 'requiresAuthentication'; // exists only on CLP + let isEntryRow = !isAuthRow && !isPublicRow; + + let publicAccess = perms.get(column).get('*'); + let auth = perms.get(column).get('requiresAuthentication'); + let checked = perms.get(column).get(rowId); + + let forceChecked = publicAccess && !auth; + let indeterminate = isPublicRow && auth; + // the logic is: + // Checkbox is shown for: + // - Public row: always + // - Authn row: always + // - Entry row: when requires auth OR not Public + let editable = isPublicRow || isAuthRow || (isEntryRow && !forceChecked); + + return { + checked, + editable, + indeterminate + }; +} + +function resolvePointerPermission(perms, pointerPerms, rowId, column) { + let publicAccess = perms.get(column) && perms.get(column).get('*'); + let auth = perms.get(column).get('requiresAuthentication'); + + // Pointer permission can be grouped as read/write + let permsGroup; - const create = perms.get('create').get(rowId) || perms.get('create').get('*'); - const update = perms.get('update').get(rowId) || perms.get('update').get('*'); - const del = perms.get('delete').get(rowId) || perms.get('delete').get('*'); + if (['get', 'find', 'count'].includes(column)) { + permsGroup = 'read'; + } + + if (['create', 'update', 'delete', 'addField'].includes(column)) { + permsGroup = 'write'; + } + + let checked = pointerPerms.get(permsGroup) || pointerPerms.get(column); //pointerPerms.get(permsGroup) && pointerPerms.get(permsGroup).get(rowId); + + let forceChecked = publicAccess && !auth; + + // Checkbox is shown for: + // - Public row: always + // - Authn row: always + // - Entry row: when requires auth OR not Public + let editable = !forceChecked; + + return { + checked, + editable + }; +} + +function renderAdvancedCheckboxes(rowId, perms, advanced, onChange) { + let get = resolvePermission(perms, rowId, 'get'); + let find = resolvePermission(perms, rowId, 'find'); + let count = resolvePermission(perms, rowId, 'count'); + let create = resolvePermission(perms, rowId, 'create'); + let update = resolvePermission(perms, rowId, 'update'); + let del = resolvePermission(perms, rowId, 'delete'); + let addField = resolvePermission(perms, rowId, 'addField'); if (advanced) { return [ -
- {!perms.get('get').get('*') || rowId === '*' ? +
+ {get.editable ? ( onChange(rowId, 'get', value)} /> : - } + label="Get" + checked={get.checked} + onChange={value => onChange(rowId, 'get', value)} + /> + ) : ( + + )}
, -
- {!perms.get('find').get('*') || rowId === '*' ? +
+ {find.editable ? ( onChange(rowId, 'find', value)} /> : - } + label="Find" + checked={find.checked} + onChange={value => onChange(rowId, 'find', value)} + /> + ) : ( + + )}
, -
- {!perms.get('count').get('*') || rowId === '*' ? +
+ {count.editable ? ( onChange(rowId, 'count', value)} /> : - } + label="Count" + checked={count.checked} + onChange={value => onChange(rowId, 'count', value)} + /> + ) : ( + + )}
, -
- {!perms.get('create').get('*') || rowId === '*' ? +
+ {create.editable ? ( onChange(rowId, 'create', value)} /> : - } + label="Create" + checked={create.checked} + onChange={value => onChange(rowId, 'create', value)} + /> + ) : ( + + )}
, -
- {!perms.get('update').get('*') || rowId === '*' ? +
+ {update.editable ? ( onChange(rowId, 'update', value)} /> : - } + label="Update" + checked={update.checked} + onChange={value => onChange(rowId, 'update', value)} + /> + ) : ( + + )}
, -
- {!perms.get('delete').get('*') || rowId === '*' ? +
+ {del.editable ? ( onChange(rowId, 'delete', value)} /> : - } + label="Delete" + checked={del.checked} + onChange={value => onChange(rowId, 'delete', value)} + /> + ) : ( + + )}
, -
- {!perms.get('addField').get('*') || rowId === '*' ? +
+ {addField.editable ? ( onChange(rowId, 'addField', value)} /> : - } -
, + label="Add field" + checked={addField.checked} + onChange={value => onChange(rowId, 'addField', value)} + /> + ) : ( + + )} +
]; } - const read = get || find || count; - const write = create || update || del; - const readChecked = get && find && count; - const writeChecked = create && update && del; + let showReadCheckbox = find.editable || get.editable || count.editable; + let showWriteCheckbox = create.editable || update.editable || del.editable; + + let readChecked = find.checked && get.checked && count.checked; + let writeChecked = create.checked && update.checked && del.checked; + + let indeterminateRead = + [get, find, count].some(s => s.checked) && + [get, find, count].some(s => !s.checked); + + let indeterminateWrite = + [create, update, del].some(s => s.checked) && + [create, update, del].some(s => !s.checked); return [ -
- {!(perms.get('get').get('*') && perms.get('find').get('*') && perms.get('count').get('*')) || rowId === '*' ? +
+ {showReadCheckbox ? ( onChange(rowId, ['get', 'find', 'count'], value)} /> : - } + indeterminate={indeterminateRead} + onChange={value => onChange(rowId, ['get', 'find', 'count'], value)} + /> + ) : ( + + )}
, -
- {!(perms.get('create').get('*') && perms.get('update').get('*') && perms.get('delete').get('*')) || rowId === '*' ? +
+ {showWriteCheckbox ? ( onChange(rowId, ['create', 'update', 'delete'], value)} /> : - } + indeterminate={indeterminateWrite} + onChange={value => + onChange(rowId, ['create', 'update', 'delete'], value) + } + /> + ) : ( + + )}
, +
+ {addField.editable ? ( + onChange(rowId, ['addField'], value)} + /> + ) : ( + + )} +
]; } function renderSimpleCheckboxes(rowId, perms, onChange) { - let readChecked = perms.get('read').get(rowId) || perms.get('read').get('*'); - let writeChecked = perms.get('write').get(rowId) || perms.get('write').get('*'); + // Public state + let allowPublicRead = perms.get('read').get('*'); + let allowPublicWrite = perms.get('write').get('*'); + + // requireAuthentication state + let onlyAuthRead = perms.get('read').get('requiresAuthentication'); + let onlyAuthWrite = perms.get('write').get('requiresAuthentication'); + + let isAuthRow = rowId === 'requiresAuthentication'; + let isPublicRow = rowId === '*'; + + let showReadCheckbox = + isAuthRow || + (!onlyAuthRead && isPublicRow) || + (!onlyAuthRead && !allowPublicRead); + let showWriteCheckbox = + isAuthRow || + (!onlyAuthWrite && isPublicRow) || + (!onlyAuthWrite && !allowPublicWrite); + + let readChecked = + perms.get('read').get(rowId) || allowPublicRead || isAuthRow; + let writeChecked = + perms.get('write').get(rowId) || allowPublicWrite || isAuthRow; + return [ -
- {!perms.get('read').get('*') || rowId === '*' ? +
+ {showReadCheckbox ? ( onChange(rowId, 'read', value)} /> : - } + onChange={value => onChange(rowId, 'read', value)} + /> + ) : ( + + )}
, -
- {!perms.get('write').get('*') || rowId === '*' ? +
+ {showWriteCheckbox ? ( onChange(rowId, 'write', value)} /> : - } -
, + onChange={value => onChange(rowId, 'write', value)} + /> + ) : ( + + )} +
]; } -function renderPointerCheckboxes(rowId, publicPerms, pointerPerms, advanced, onChange) { - const publicRead = publicPerms.get('get').get('*') && - publicPerms.get('find').get('*') && - publicPerms.get('count').get('*') - const publicWrite = publicPerms.get('create').get('*') && - publicPerms.get('update').get('*') && - publicPerms.get('delete').get('*') && - publicPerms.get('addField').get('*'); +function renderPointerCheckboxes( + rowId, + perms, + pointerPerms, + advanced, + onChange +) { + let get = resolvePointerPermission(perms, pointerPerms, rowId, 'get'); + let find = resolvePointerPermission(perms, pointerPerms, rowId, 'find'); + let count = resolvePointerPermission(perms, pointerPerms, rowId, 'count'); + let create = resolvePointerPermission(perms, pointerPerms, rowId, 'create'); + let update = resolvePointerPermission(perms, pointerPerms, rowId, 'update'); + let del = resolvePointerPermission(perms, pointerPerms, rowId, 'delete'); + let addField = resolvePointerPermission( + perms, + pointerPerms, + rowId, + 'addField' + ); + + // whether this field is listed under readUserFields[] + let readUserFields = pointerPerms.get('read'); + // or writeUserFields[] + let writeUserFields = pointerPerms.get('write'); + + let read = { + checked: readUserFields || (get.checked && find.checked && count.checked), + editable: true + }; + + let write = { + checked: + writeUserFields || + (create.checked && update.checked && del.checked && addField.checked), + editable: true + }; - if (!advanced) { - return [ -
- {!publicRead ? - onChange(rowId, 'read', value)} /> : - } -
, -
- {!publicWrite ? - onChange(rowId, 'write', value)} /> : - } -
, - ]; - } let cols = []; - if (publicRead) { - cols.push( -
- -
- ); + + if (!advanced) { + // simple view mode + // detect whether public access is enabled + + //for read + let publicReadGrouped = perms.getIn(['read', '*']); + let publicReadGranular = + perms.getIn(['get', '*']) && + perms.getIn(['find', '*']) && + perms.getIn(['count', '*']); + + // for write + let publicWriteGrouped = perms.getIn(['write', '*']); + let publicWriteGranular = + perms.getIn(['create', '*']) && + perms.getIn(['update', '*']) && + perms.getIn(['delete', '*']) && + perms.getIn(['addField', '*']); + + // assume public access is on when it is set either for group or for each operation + let publicRead = publicReadGrouped || publicReadGranular; + let publicWrite = publicWriteGrouped || publicWriteGranular; + + // -------------- + // detect whether auth is required + // for read + let readAuthGroup = perms.getIn(['read', 'requiresAuthentication']); + let readAuthSeparate = + perms.getIn(['get', 'requiresAuthentication']) && + perms.getIn(['find', 'requiresAuthentication']) && + perms.getIn(['count', 'requiresAuthentication']); + + // for write + let writeAuthGroup = perms.getIn(['write', 'requiresAuthentication']); + let writeAuthSeparate = + perms.getIn(['create', 'requiresAuthentication']) && + perms.getIn(['update', 'requiresAuthentication']) && + perms.getIn(['delete', 'requiresAuthentication']) && + perms.getIn(['addField', 'requiresAuthentication']); + + // assume auth is required when it's set either for group or for each operation + let readAuth = readAuthGroup || readAuthSeparate; + let writeAuth = writeAuthGroup || writeAuthSeparate; + + // when all ops have public access and none requiure auth, show non-editable checked icon + let readForceChecked = publicRead && !readAuth; + let writeForceChecked = publicWrite && !writeAuth; + + // -------------- + // detect whether to show indeterminate checkbox (dash icon) + // in simple view indeterminate happens when: + // {read/write}UserFields is not set and + // not all permissions have same value !(all checked || all unchecked) + let indeterminateRead = + !readUserFields && + [get, find, count].some(s => s.checked) && + [get, find, count].some(s => !s.checked); + + let indeterminateWrite = + !writeUserFields && + [create, update, del, addField].some(s => s.checked) && + [create, update, del, addField].some(s => !s.checked); + cols.push( -
- +
+ {readForceChecked ? ( + + ) : ( + onChange(rowId, 'read', value)} + /> + )}
); + + if (writeForceChecked) { + cols.push( +
+ +
, +
+ +
+ ); + } else { + cols.push( +
+
+ onChange(rowId, 'write', value)} + /> +
+
+ ); + } + } else { + // in advanced view mode cols.push( -
- +
+ {get.editable ? ( + onChange(rowId, 'get', value)} + /> + ) : ( + + )}
); - } else { cols.push( -
-
+
+ {find.editable ? ( onChange(rowId, 'read', value)} /> -
+ label="Find" + checked={find.checked} + onChange={value => onChange(rowId, 'find', value)} + /> + ) : ( + + )}
); - } - if (publicWrite) { cols.push( -
- +
+ {count.editable ? ( + onChange(rowId, 'count', value)} + /> + ) : ( + + )}
); + cols.push( -
- +
+ {create.editable ? ( + onChange(rowId, 'create', value)} + /> + ) : ( + + )}
); cols.push( -
- +
+ {update.editable ? ( + onChange(rowId, 'update', value)} + /> + ) : ( + + )}
); cols.push( -
- +
+ {del.editable ? ( + onChange(rowId, 'delete', value)} + /> + ) : ( + + )}
); - } else { cols.push( -
-
+
+ {addField.editable ? ( onChange(rowId, 'write', value)} /> -
+ label="Add field" + checked={addField.checked} + onChange={value => onChange(rowId, 'addField', value)} + /> + ) : ( + + )}
); } return cols; } +const intersectionMargin = '10px 0px 0px 20px'; export default class PermissionsDialog extends React.Component { - constructor({ - permissions, - advanced, - }) { - super(); + constructor(props) { + super(props); + + const { permissions, advanced, columns } = props; + + this.refEntry = React.createRef(null); + this.refTable = React.createRef(null); + this.refScrollIndicator = React.createRef(null); - let uniqueKeys = ['*']; + const callback = ([entry]) => { + const ratio = entry.intersectionRatio; + const hidden = ratio < 0.92; + // hide suggestions to avoid ugly footer overlap + this.refEntry.current.setHidden(hidden); + // also show indicator when input is not visible + this.refScrollHint.current.toggleActive(hidden); + }; + + this.observer = new IntersectionObserver(callback, { + root: this.refTable.current, + rootMargin: intersectionMargin, + threshold: [0.92] + }); + + this.suggestInput = this.suggestInput.bind(this); + this.buildLabel = this.buildLabel.bind(this); + + let uniqueKeys = [...(advanced ? ['requiresAuthentication'] : []), '*']; let perms = {}; for (let k in permissions) { - if (k !== 'readUserFields' && k !== 'writeUserFields') { - Object.keys(permissions[k]).forEach((key) => { + if ( + k !== 'readUserFields' && + k !== 'writeUserFields' && + k !== 'protectedFields' + ) { + Object.keys(permissions[k]).forEach(key => { + if (key === 'pointerFields') { + //pointerFields is not a regular entity; processed later + return; + } if (uniqueKeys.indexOf(key) < 0) { uniqueKeys.push(key); } + + // requireAuthentication is only available for CLP + if (advanced) { + if (!permissions[k].requiresAuthentication) { + permissions[k].requiresAuthentication = false; + } + } }); perms[k] = Map(permissions[k]); } } + + let pointerPermsSubset = { + read: permissions.readUserFields || [], + write: permissions.writeUserFields || [] + }; + if (advanced) { // Fill any missing fields perms.get = perms.get || Map(); @@ -265,47 +588,96 @@ export default class PermissionsDialog extends React.Component { perms.update = perms.update || Map(); perms.delete = perms.delete || Map(); perms.addField = perms.addField || Map(); + + (pointerPermsSubset.get = perms.get.pointerFields || []), + (pointerPermsSubset.find = perms.find.pointerFields || []), + (pointerPermsSubset.count = perms.count.pointerFields || []), + (pointerPermsSubset.create = perms.create.pointerFields || []), + (pointerPermsSubset.update = perms.update.pointerFields || []), + (pointerPermsSubset.delete = perms.delete.pointerFields || []), + (pointerPermsSubset.addField = perms.addField.pointerFields || []); } let pointerPerms = {}; - if (permissions.readUserFields) { - permissions.readUserFields.forEach((f) => { - let p = { read: true }; - if (permissions.writeUserFields && permissions.writeUserFields.indexOf(f) > -1) { - p.write = true; - } - pointerPerms[f] = Map(p); - }); + + // form an object where each pointer-field name holds operations it has access to + // e.g. { [field]: { read: true, create: true}, [field2]: {read: true,} ...} + for (const action in pointerPermsSubset) { + // action holds array of field names + for (const field of pointerPermsSubset[action]) { + pointerPerms[field] = Object.assign( + { [action]: true }, + pointerPerms[field] + ); + } } - if (permissions.writeUserFields) { - permissions.writeUserFields.forEach((f) => { - if (!pointerPerms[f]) { - pointerPerms[f] = Map({ write: true }); - } - }); + // preserve protectedFields + if (permissions.protectedFields) { + perms.protectedFields = permissions.protectedFields; } this.state = { transitioning: false, showLevels: false, level: 'Simple', // 'Simple' | 'Advanced' - + entryTypes: undefined, perms: Map(perms), // Permissions map keys: uniqueKeys, // Permissions row order - pointerPerms: Map(pointerPerms), // Pointer permissions map + pointerPerms: Map(fromJS(pointerPerms)), // Pointer permissions map pointers: Object.keys(pointerPerms), // Pointer order - + columns, newEntry: '', entryError: null, - newKeys: [], // Order for new entries + newKeys: [] // Order for new entries }; } + async componentDidMount() { + // validate existing entries, also preserve their types + // to render correct pills and details. + const rows = await Promise.all( + this.state.keys + .filter(key => !['requiresAuthentication', '*'].includes(key)) + .map(key => this.props.validateEntry(key)) + ); + + let entryTypes = new Map({}); + + for (const { entry, type } of rows) { + let key; + let value = {}; + + if (type === 'user') { + key = entry.id; + value[type] = { + name: entry.get('username'), + id: entry.id + }; + } + + if (type === 'role') { + key = 'role:' + entry.getName(); + value[type] = { + name: entry.getName(), + id: entry.id + }; + } + + if (type === 'pointer') { + key = entry; + value[type] = true; + } + entryTypes = entryTypes.set(key, value); + } + + this.setState({ entryTypes }); + } + toggleField(rowId, type, value) { - this.setState((state) => { + this.setState(state => { let perms = state.perms; if (Array.isArray(type)) { - type.forEach((t) => { + type.forEach(t => { perms = perms.setIn([t, rowId], value); }); } else { @@ -315,77 +687,150 @@ export default class PermissionsDialog extends React.Component { }); } - togglePointer(field, type, value) { - this.setState((state) => { - let pointerPerms = state.pointerPerms.setIn([field, type], value); + togglePointer(field, action, value) { + this.setState(state => { + let pointerPerms = state.pointerPerms; + + // toggle the value clicked + pointerPerms = pointerPerms.setIn([field, action], value); + + const readGroup = ['get', 'find', 'count']; + const writeGroup = ['create', 'update', 'delete', 'addField']; + + // since there're two ways a permission can be granted for field ({read/write}UserFields:['field'] or action: pointerFields:['field'] ) + // both views (advanced/simple) need to be in sync + // e.g. + // read is true (checked in simple view); then 'get' changes true->false in advanced view - 'read' should be also unset in simple view + + // when read/write changes - also update all individual actions with new value + if (action === 'read') { + for (const op of readGroup) { + pointerPerms = pointerPerms.setIn([field, op], value); + } + } else if (action === 'write') { + for (const op of writeGroup) { + pointerPerms = pointerPerms.setIn([field, op], value); + } + } else { + const groupKey = readGroup.includes(action) ? 'read' : 'write'; + const group = groupKey === 'read' ? readGroup : writeGroup; + + // if granular action changed to true + if (value) { + // if all become checked, unset them as granulars and enable write group instead + if (!group.some(op => !pointerPerms.getIn([field, op]))) { + for (const op of group) { + pointerPerms = pointerPerms.setIn([field, op], false); + } + pointerPerms = pointerPerms.setIn([field, groupKey], true); + } + } + // if granular action changed to false + else { + // if group was checked on simple view / {read/write}UserFields contained this field + if (pointerPerms.getIn([field, groupKey])) { + // unset value for group + pointerPerms = pointerPerms.setIn([field, groupKey], false); + // and enable all granular actions except the one unchecked + group + .filter(op => op !== action) + .forEach(op => { + pointerPerms = pointerPerms.setIn([field, op], true); + }); + } + } + } return { pointerPerms }; }); } - handleKeyDown(e) { - if (e.keyCode === 13) { - this.checkEntry(); - } - } - - checkEntry() { - if (this.state.newEntry === '') { + checkEntry(input) { + if (input === '') { return; } if (this.props.validateEntry) { - this.props.validateEntry(this.state.newEntry).then((type) => { - if (type.user || type.role) { - let id = type.user ? type.user.id : 'role:' + type.role.getName(); - if (this.state.keys.indexOf(id) > -1 || this.state.newKeys.indexOf(id) > -1) { + this.props.validateEntry(input).then( + ({ type, entry }) => { + let next = { [type]: entry }; + + if (next.public) { return this.setState({ - entryError: 'You already have a row for this object' - }) + entryError: 'You already have a row for Public' + }); } + let id, name, key, newEntry; + + let nextKeys; + let nextEntryTypes; let nextPerms = this.state.perms; + + if (next.user || next.role) { + id = next.user ? next.user.id : next.role.id; + name = next.user ? next.user.get('username') : next.role.getName(); + + key = next.user ? id : 'role:' + name; + newEntry = { [type]: { name, id } }; + } else if (next.pointer) { + key = next.pointer; + newEntry = { [type]: true }; + } else { + return this.setState({ + entryError: 'Unsupported entry' + }); + } + + // check if key already in list + if ( + this.state.keys.indexOf(key) > -1 || + this.state.newKeys.indexOf(key) > -1 + ) { + return this.setState({ + entryError: 'You already have a row for this object' + }); + } + + // create new permissions if (this.props.advanced) { - nextPerms = nextPerms.setIn(['get', id], true); - nextPerms = nextPerms.setIn(['find', id], true); - nextPerms = nextPerms.setIn(['count', id], true); - nextPerms = nextPerms.setIn(['create', id], true); - nextPerms = nextPerms.setIn(['update', id], true); - nextPerms = nextPerms.setIn(['delete', id], true); - nextPerms = nextPerms.setIn(['addField', id], true); + nextPerms = nextPerms.setIn(['get', key], true); + nextPerms = nextPerms.setIn(['find', key], true); + nextPerms = nextPerms.setIn(['count', key], true); + nextPerms = nextPerms.setIn(['create', key], true); + nextPerms = nextPerms.setIn(['update', key], true); + nextPerms = nextPerms.setIn(['delete', key], true); + nextPerms = nextPerms.setIn(['addField', key], true); } else { - nextPerms = nextPerms.setIn(['read', id], true); - nextPerms = nextPerms.setIn(['write', id], true); + nextPerms = nextPerms.setIn(['read', key], true); + nextPerms = nextPerms.setIn(['write', key], true); } - let nextKeys = this.state.newKeys.concat([id]); - return this.setState({ - perms: nextPerms, - newKeys: nextKeys, - newEntry: '', - entryError: null, - }); - } - if (type.pointer) { - let nextPerms = this.state.pointerPerms.set(type.pointer, Map({ read: true, write: true })); - let nextKeys = this.state.newKeys.concat('pointer:' + type.pointer); - - this.setState({ - pointerPerms: nextPerms, - newKeys: nextKeys, - newEntry: '', - entryError: null, - }); - } - }, () => { - if (this.props.advanced && this.props.enablePointerPermissions) { - this.setState({ - entryError: 'Role, User or pointer field not found. Enter a valid Role name, Username, User ID or User pointer field name.' - }); - } else { - this.setState({ - entryError: 'Role or User not found. Enter a valid Role name, Username, or User ID.' - }); + nextKeys = this.state.newKeys.concat([key]); + nextEntryTypes = this.state.entryTypes.set(key, newEntry); + + return this.setState( + { + perms: nextPerms, + newKeys: nextKeys, + entryTypes: nextEntryTypes, + newEntry: '', + entryError: null + }, + () => this.refEntry.current.resetInput() + ); + }, + () => { + if (this.props.advanced && this.props.enablePointerPermissions) { + this.setState({ + entryError: + 'Role, User or field not found. Enter a valid id, name or column' + }); + } else { + this.setState({ + entryError: 'Role or User not found. Enter a valid name or id.' + }); + } } - }) + ); } } @@ -400,7 +845,7 @@ export default class PermissionsDialog extends React.Component { pointerPerms: this.state.pointerPerms.delete(key) }); } - index = this.state.newKeys.indexOf('pointer:' + key); + index = this.state.newKeys.indexOf(key); if (index > -1) { let filtered = this.state.newKeys.concat([]); filtered.splice(index, 1); @@ -422,9 +867,7 @@ export default class PermissionsDialog extends React.Component { .deleteIn(['delete', key]) .deleteIn(['addField', key]); } else { - newPerms = newPerms - .deleteIn(['read', key]) - .deleteIn(['write', key]); + newPerms = newPerms.deleteIn(['read', key]).deleteIn(['write', key]); } if (index > -1) { let filtered = this.state.keys.concat([]); @@ -448,14 +891,29 @@ export default class PermissionsDialog extends React.Component { outputPerms() { let output = {}; - let fields = [ 'read', 'write' ]; + let fields = ['read', 'write']; if (this.props.advanced) { - fields = [ 'get', 'find', 'count', 'create', 'update', 'delete', 'addField' ]; + fields = [ + 'get', + 'find', + 'count', + 'create', + 'update', + 'delete', + 'addField' + ]; } - fields.forEach((field) => { + fields.forEach(field => { output[field] = {}; this.state.perms.get(field).forEach((v, k) => { + if (k === 'pointerFields') { + return; + } + if (k === 'requiresAuthentication' && !v){ + // only acceppt requiresAuthentication with true + return + } if (v) { output[field][k] = true; } @@ -471,28 +929,90 @@ export default class PermissionsDialog extends React.Component { if (perms.get('write')) { writeUserFields.push(key); } + + fields.forEach(op => { + if (perms.get(op)) { + if (!output[op].pointerFields) { + output[op].pointerFields = []; + } + + output[op].pointerFields.push(key); + } + }); }); + if (readUserFields.length) { output.readUserFields = readUserFields; } if (writeUserFields.length) { output.writeUserFields = writeUserFields; } + // should also preserve protectedFields! + if (this.state.perms.get('protectedFields')) { + output.protectedFields = this.state.perms.get('protectedFields'); + } return output; } - renderRow(key, forcePointer) { - let pointer = !!forcePointer; + renderRow(key, columns, types) { + const pill = text => ( + + + + ); + + // types is immutable.js Map + const type = (types && types.get(key)) || {}; + + let pointer; let label = {key}; - if (key.startsWith('role:')) { - label = {key.substr(5)} (Role); - } else if (key.startsWith('pointer:')) { - pointer = true; - key = key.substr(8); - } - if (pointer) { - label = {key} ; + + if (type.user) { + label = ( + +

+ + {type.user.id} + {pill('User')} + +

+

+ {'username: '} + {type.user.name} +

+
+ ); + } else if (type.role) { + label = ( + +

+ + {'role:'} + {type.role.name} + +

+

+ id: {type.role.id} +

+
+ ); + } else if (type.pointer) { + // get class info from schema + let { type, targetClass } = columns[key]; + + let pillText = type + (targetClass ? `<${targetClass}>` : ''); + + label = ( + +

+ {key} + {pill(pillText)} +

+

Only users pointed to by this field

+
+ ); } + let content = null; if (!this.state.transitioning) { if (pointer) { @@ -511,15 +1031,23 @@ export default class PermissionsDialog extends React.Component { this.toggleField.bind(this) ); } else { - content = renderSimpleCheckboxes(key, this.state.perms, this.toggleField.bind(this)); + content = renderSimpleCheckboxes( + key, + this.state.perms, + this.toggleField.bind(this) + ); } } let trash = null; if (!this.state.transitioning) { trash = ( ); @@ -545,11 +1073,76 @@ export default class PermissionsDialog extends React.Component { this.toggleField.bind(this) ); } - return renderSimpleCheckboxes('*', this.state.perms, this.toggleField.bind(this)); + return renderSimpleCheckboxes( + '*', + this.state.perms, + this.toggleField.bind(this) + ); + } + + renderAuthenticatedCheckboxes() { + if (this.state.transitioning) { + return null; + } + if (this.props.advanced) { + return renderAdvancedCheckboxes( + 'requiresAuthentication', + this.state.perms, + this.state.level === 'Advanced', + this.toggleField.bind(this) + ); + } + return null; + } + + buildLabel(input) { + let label; + if (input.startsWith('userField:')) { + label = 'Name of field with pointer(s) to User'; + } else if (input.startsWith('user:')) { + label = 'Find User by id or name'; + } else if (input.startsWith('role:')) { + label = 'Find Role by id or name'; + } + + return label; + } + + suggestInput(input) { + // role: user: suggested if entry empty or does start with not already start with any of them + // and not - suggestedPrefix is currently set + const userPointers = this.props.userPointers || []; + + const keys = this.state.keys; + const newKeys = this.state.newKeys; + const allKeys = [...keys, ...newKeys]; + + // "userPointer:" fields that were not added yet + let unusedPointerFields = userPointers.filter( + ptr => !allKeys.includes(ptr) && ptr.includes(input) + ); + + // roles + let prefixes = ['role:'] + .filter(o => o.startsWith(input) && o.length > input.length) // filter matching input + .concat(...unusedPointerFields); + + // pointer fields that are not applied yet; + let availableFields = []; + + availableFields.push(...prefixes); + + return availableFields; } render() { let classes = [styles.dialog, unselectable]; + + // for 3-column CLP dialog + if (this.props.advanced) { + classes.push(styles.clp); + } + if (this.state.level === 'Advanced') { classes.push(styles.advanced); } @@ -562,16 +1155,31 @@ export default class PermissionsDialog extends React.Component { } return ( - +
{this.props.title} - {this.props.advanced ? -
this.setState(({ showLevels }) => ({ showLevels: !showLevels }))}> - -
: null} - {this.props.advanced && this.state.showLevels ? -
: null} + {this.props.advanced ? ( +
+ this.setState(({ showLevels }) => ({ + showLevels: !showLevels + })) + } + > + +
+ ) : null} + {this.props.advanced && this.state.showLevels ? ( +
+ ) : null}
@@ -580,15 +1188,19 @@ export default class PermissionsDialog extends React.Component { darkBg={true} value={this.state.level} type={Toggle.Types.TWO_WAY} - optionLeft='Simple' - optionRight='Advanced' - onChange={(level) => { + optionLeft="Simple" + optionRight="Advanced" + onChange={level => { if (this.state.transitioning || this.state.level === level) { return; } this.setState({ level, transitioning: true }); - setTimeout(() => this.setState({ transitioning: false }), 700); - }} /> + setTimeout( + () => this.setState({ transitioning: false }), + 700 + ); + }} + />
@@ -596,44 +1208,78 @@ export default class PermissionsDialog extends React.Component {
Write
Add
-
-
+
+
-
- Public -
+
Public
{this.renderPublicCheckboxes()}
- {this.state.keys.slice(1).map((key) => this.renderRow(key))} - {this.props.advanced ? - this.state.pointers.map((pointer) => this.renderRow(pointer, true)) : - null} - {this.state.newKeys.map((key) => this.renderRow(key))} + {this.props.advanced ? ( +
+
Authenticated
+ {this.renderAuthenticatedCheckboxes()} +
+ ) : null} + + {this.state.keys + .slice(this.props.advanced ? 2 : 1) + .map(key => + this.renderRow(key, this.state.columns, this.state.entryTypes) + )} + {this.props.advanced + ? this.state.pointers.map(pointer => + this.renderRow(pointer, true) + ) + : null} + {this.state.newKeys.map(key => + this.renderRow(key, this.state.columns, this.state.entryTypes) + )} +
- this.setState({ newEntry: e.target.value })} - onBlur={this.checkEntry.bind(this)} - onKeyDown={this.handleKeyDown.bind(this)} - placeholder={placeholderText} /> + + { + this.setState({ newEntry: input, entryError: undefined }); + }} + onSubmit={this.checkEntry.bind(this)} + placeholder={placeholderText} + buildSuggestions={input => this.suggestInput(input)} + buildLabel={input => this.buildLabel(input)} + error={this.state.entryError} + /> +
+
-
{this.props.details} diff --git a/src/components/PermissionsDialog/PermissionsDialog.scss b/src/components/PermissionsDialog/PermissionsDialog.scss index 841ae6d600..48e3090878 100644 --- a/src/components/PermissionsDialog/PermissionsDialog.scss +++ b/src/components/PermissionsDialog/PermissionsDialog.scss @@ -5,298 +5,352 @@ * This source code is licensed under the license found in the LICENSE file in * the root directory of this source tree. */ -@import 'stylesheets/globals.scss'; - -$labelWidth: 330px; -$colWidth: 82px; -$writeColWidth: 92px; -$addFieldColWidth: 118px; -$deleteColWidth: 44px; - -$sumReadColsWidth: calc(3 * #{$colWidth}); -$sumWriteColsWidth: calc(3 * #{$writeColWidth}); - -$permissionsDialogWidth: calc(#{$labelWidth} + (2 * #{$colWidth}) + #{$deleteColWidth}); -$permissionsDialogMaxWidth: calc(#{$labelWidth} + #{$sumReadColsWidth} + #{$sumWriteColsWidth} + #{$addFieldColWidth} + #{$deleteColWidth}); - -.dialog { - @include modalAnimation(); - position: absolute; - top: 50%; - left: 50%; - width: $permissionsDialogWidth; - background: white; - border-radius: 5px; - overflow: hidden; - transition: width 0.3s 0.15s ease-out; -} - -.header { - height: 50px; - background: $blue; - position: relative; - color: #ffffff; - line-height: 50px; - font-size: 16px; - text-align: center; - - .settings { - position: absolute; - top: 15px; - right: 15px; - cursor: pointer; - - svg { - fill: #0E69A1; - vertical-align: top; - } - - &:hover svg { - fill: #094367; - } - } - - .arrow { - position: absolute; - @include arrow('up', 12px, 6px, #0E69A1); - top: 44px; - right: 19px; - } -} - -.level { - height: 50px; - width: $permissionsDialogWidth; - background: #0E69A1; - position: relative; - color: white; - transition: width 0.3s 0.15s ease-out; - - > div { - margin: 0; - position: absolute; - top: 10px; - right: 10px; - } - - > span { - display: inline-block; - font-size: 12; - height: 50px; - line-height: 50px; - padding-left: 20px; - } -} - -.tableWrap { - height: 250px; - overflow-y: auto; - overflow-x: hidden; -} - -.second, .third, .fourth { - width: $colWidth; -} -.fifth, .sixth, .seventh { - width: $writeColWidth; -} -.eighth { - width: $addFieldColWidth; -} -.nineth { - width: $deleteColWidth; -} - -.table { - position: relative; - min-height: 250px; - - .overlay { - position: absolute; - top: 0; - bottom: 0; - pointer-events: none; - background: rgba(0,0,40,0.03); - - &.second { - left: $labelWidth; - } - &.fourth { - left: calc(#{$labelWidth} + (2 * #{$colWidth})); - } - &.sixth { - left: calc(#{$labelWidth} + #{$sumReadColsWidth} + #{$writeColWidth}); - } - &.eighth { - left: calc(#{$labelWidth} + #{$sumReadColsWidth} + #{$sumWriteColsWidth}); - } - } -} - -.footer { - position: relative; - height: 51px; - border-top: 1px solid #e3e3ea; - - .details { - font-size: 12px; - padding-left: 20px; - - a { - color: $blue; - } - } - - .actions { - float: right; - padding: 10px 15px; - - a { - margin-left: 10px; - } - } -} - -.headers { - overflow: hidden; - transition: height .3s cubic-bezier(0.645,0.045,0.355,1) .5s; - background: #56AEE3; - height: 0; - padding-left: $labelWidth; - text-align: center; - color: white; - font-size: 12px; - - div { - float: left; - border-left: 1px solid white; - height: 20px; - line-height: 20px; - vertical-align: top; - } -} - -.readHeader { - width: $sumReadColsWidth; -} - -.writeHeader { - width: $sumWriteColsWidth; -} - -.addHeader { - width: $addFieldColWidth; - border-right: 1px solid white; -} - -.advanced { - width: $permissionsDialogMaxWidth; - - .level { - width: $permissionsDialogMaxWidth; - } - - .headers { - height: 20px; - } -} - -.row { - height: 50px; - line-height: 50px; + @import 'stylesheets/globals.scss'; + + $labelWidth: 330px; + $colWidth: 82px; + $writeColWidth: 92px; + $addFieldColWidth: 118px; + $deleteColWidth: 44px; + + $sumReadColsWidth: calc(3 * #{$colWidth}); + $sumWriteColsWidth: calc(3 * #{$writeColWidth}); + + $permissionsDialogWidth: calc(#{$labelWidth} + (2 * #{$colWidth}) + #{$deleteColWidth}); + $permissionsDialogMaxWidth: calc(#{$labelWidth} + #{$sumReadColsWidth} + #{$sumWriteColsWidth} + #{$addFieldColWidth} + #{$deleteColWidth}); + + $simplePointerWriteWidth: calc( #{$colWidth} + #{$addFieldColWidth}); + $pointerWriteWidth: calc( #{$sumWriteColsWidth} + #{$addFieldColWidth}); + + $clpDialogWidth: calc(#{$permissionsDialogWidth} + #{$addFieldColWidth}); + + .dialog { + @include modalAnimation(); + position: absolute; + top: 50%; + left: 50%; + width: $permissionsDialogWidth; + background: white; + border-radius: 5px; + overflow: hidden; + transition: width 0.3s 0.15s ease-out; + } + + .clp{ + width: $clpDialogWidth; + + .level{ + width: $clpDialogWidth; + } + // 118px for add field in CLP only + .fourth { + width: $addFieldColWidth; + } + } + + .clp.advanced{ + .fourth { + width: $colWidth; + } + } + + .header { + height: 50px; + background: $blue; + position: relative; + color: #ffffff; + line-height: 50px; + font-size: 16px; + text-align: center; + + .settings { + position: absolute; + top: 15px; + right: 15px; + cursor: pointer; + + svg { + fill: #0E69A1; + vertical-align: top; + } + + &:hover svg { + fill: #094367; + } + } + + .arrow { + position: absolute; + @include arrow('up', 12px, 6px, #0E69A1); + top: 44px; + right: 19px; + } + } + + + .level { + height: 50px; + width: $permissionsDialogWidth; // width: 658px; + background: #0E69A1; + position: relative; + color: white; + transition: width 0.3s 0.15s ease-out; + + > div { + margin: 0; + position: absolute; + top: 10px; + right: 10px; + } + + > span { + display: inline-block; + font-size: 12; + height: 50px; + line-height: 50px; + padding-left: 20px; + } + } + + .tableWrap { + height: 300px; + overflow-y: auto; + overflow-x: hidden; + } + + .second, .third, .fourth { + width: $colWidth; + } + .fifth, .sixth, .seventh { + width: $writeColWidth; + } + .eighth { + width: $addFieldColWidth; + } + .nineth { + width: $deleteColWidth; + } + + .table { + position: relative; + min-height:300px; + + .overlay { + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; + background: rgba(0,0,40,0.03); + + &.second { + left: $labelWidth; + } + &.fourth { + left: calc(#{$labelWidth} + (2 * #{$colWidth})); + } + &.sixth { + left: calc(#{$labelWidth} + #{$sumReadColsWidth} + #{$writeColWidth}); + } + &.eighth { + left: calc(#{$labelWidth} + #{$sumReadColsWidth} + #{$sumWriteColsWidth}); + } + } + } + + .footer { + position: relative; + height: 51px; + border-top: 1px solid #e3e3ea; + + .details { + font-size: 12px; + padding-left: 20px; + + a { + color: $blue; + } + } + + .actions { + float: right; + padding: 10px 15px; + + a { + margin-left: 10px; + } + } + } + + .headers { + overflow: hidden; + transition: height .3s cubic-bezier(0.645,0.045,0.355,1) .5s; + background: #56AEE3; + height: 0; + padding-left: $labelWidth; + text-align: center; + color: white; + font-size: 12px; + + div { + float: left; + border-left: 1px solid white; + height: 20px; + line-height: 20px; + vertical-align: top; + } + } + + .readHeader { + width: $sumReadColsWidth; + } + + .writeHeader { + width: $sumWriteColsWidth; + } + + .addHeader { + width: $addFieldColWidth; + border-right: 1px solid white; + } + + .advanced { + width: $permissionsDialogMaxWidth; + + .level { + width: $permissionsDialogMaxWidth; + } + + .headers { + height: 20px; + } + } + + .row { + display: flex; + min-height: 50px; + height: 100%; + vertical-align: middle; font-size: 15px; border-bottom: 1px solid #e3e3ea; white-space: nowrap; - &:nth-child(odd) { - background: rgba(14, 105, 161, 0.03); - } -} - -.row.public { - background: rgba(22, 156, 238, 0.18); - border-bottom: 1px solid #0E69A1; - color: $blue; -} - -.label { - display: inline-block; + &:nth-child(odd) { + background: rgba(14, 105, 161, 0.03); + } + } + + .row.public { + background: rgba(22, 156, 238, 0.18); + border-bottom: 1px solid #0E69A1; + color: $blue; + } + + + .label { + display: inline-flex; width: $labelWidth; padding: 0 20px; -} -.check { - display: inline-block; - text-align: center; - - > svg { - fill: $blue; - vertical-align: middle; - } + flex-direction: column; + align-self: center; } -.pointerRead { - display: inline-block; - width: $sumReadColsWidth; - padding: 5px 10px; -} -.pointerWrite { - display: inline-block; - width: calc(#{$sumWriteColsWidth} + #{$addFieldColWidth}); - padding: 5px 10px; -} - -.checkboxWrap { - position: relative; - vertical-align: top; - border: 1px solid #e3e3ea; - height: 40px; - line-height: 40px; - text-align: center; - border-radius: 5px; - background: white; + .check { + display: inline-flex; + flex-direction: column; + align-self: center; + text-align: center; + + > svg { + fill: $blue; + vertical-align: middle; + align-self: center; + } + } + + .pointerRead { + display: inline-block; + width: $sumReadColsWidth; + padding: 5px 10px; + } + + .pointerWrite { + width: $simplePointerWriteWidth; + padding: 0px 10px; + display: inline-block; + } + + .checkboxWrap { + position: relative; + vertical-align: top; + border: 1px solid #e3e3ea; + height: 40px; + line-height: 40px; + text-align: center; + border-radius: 5px; + background: white; + } + + .entry { + height: 30px; + width: 290px; + border: 1px solid $mainTextColor; + border-radius: 5px; + font-size: 14px; + outline: none; + padding: 0 6px; + margin: 10px 0 0 20px; + vertical-align: top; + } + + .error { + border-color: $red; + color: $red; + } + + .delete { + display: inline-block; + vertical-align: top; + width: 32px; + height: 50px; + padding-top: 15px; + text-align: right; + + svg { + vertical-align: top; + cursor: pointer; + fill: #C1C7CD; + + &:hover { + fill: $red; + } + } + } + + .pillHolder{ + max-width: 100px; + position: relative; + display: inline-block; + top: 5px; + } + + .pillType{ + width: auto; + display: inline-flex; + height: 20; + padding: 2px 8px 0 8px; } -.entry { - height: 30px; - width: 290px; - border: 1px solid $mainTextColor; - border-radius: 5px; - font-size: 14px; - outline: none; - padding: 0 6px; - margin: 10px 0 0 20px; - vertical-align: top; +.prefix { + color: #0E69A1; } -.error { - border-color: $red; - color: $red; -} - -.delete { - display: inline-block; - vertical-align: top; - width: 32px; - height: 50px; - padding-top: 15px; - text-align: right; - - svg { - vertical-align: top; - cursor: pointer; - fill: #C1C7CD; - - &:hover { - fill: $red; - } - } +.hint{ + color: $secondaryTextColor; + font-size: 0.8em; } -.pillHolder{ - max-width: 100px; - position: relative; - display: inline-block; - top: 5px; +.selectable{ + user-select: text; } -@include modalKeyframes(); + @include modalKeyframes(); diff --git a/src/components/Popover/Popover.react.js b/src/components/Popover/Popover.react.js index fb5ed01b9e..4c5900035d 100644 --- a/src/components/Popover/Popover.react.js +++ b/src/components/Popover/Popover.react.js @@ -5,45 +5,43 @@ * This source code is licensed under the license found in the LICENSE file in * the root directory of this source tree. */ -import PropTypes from 'lib/PropTypes'; -import hasAncestor from 'lib/hasAncestor'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import styles from 'components/Popover/Popover.scss'; +import PropTypes from 'lib/PropTypes'; +import hasAncestor from 'lib/hasAncestor'; +import React from 'react'; +import styles from 'components/Popover/Popover.scss'; +import ParseApp from 'lib/ParseApp'; +import { createPortal } from 'react-dom'; -// We use this component to proxy the current tree's context (just the React Router history for now) to the new tree -export class ContextProxy extends React.Component { - getChildContext() { - return this.props.cx; - } +// We use this component to proxy the current tree's context +// (React Router history and ParseApp) to the new tree +export default class Popover extends React.Component { + constructor(props) { + super(props); + this._checkExternalClick = this._checkExternalClick.bind(this); - render() { - return this.props.children; + this._popoverLayer = document.createElement('div'); } -} -ContextProxy.childContextTypes = { - history: PropTypes.object, - router: PropTypes.object, - currentApp: PropTypes.object -}; - -export default class Popover extends React.Component { - constructor() { - super(); - this._checkExternalClick = this._checkExternalClick.bind(this); + componentDidUpdate(prevState) { + if (this.props.position !== prevState.position) { + this._popoverLayer.style.left = this.props.position.x + 'px'; + this._popoverLayer.style.top = this.props.position.y + 'px'; + } } - componentWillMount() { - let wrapperStyle = this.props.fixed ? - styles.fixed_wrapper : - styles.popover_wrapper; - this._popoverWrapper = document.getElementById(wrapperStyle); + + componentDidMount() { if (!this._popoverWrapper) { this._popoverWrapper = document.createElement('div'); - this._popoverWrapper.id = wrapperStyle; document.body.appendChild(this._popoverWrapper); } - this._popoverLayer = document.createElement('div'); + + let wrapperStyle = this.props.fixed + ? styles.fixed_wrapper + : styles.popover_wrapper; + + this._popoverWrapper.className = wrapperStyle; + this._popoverWrapper.appendChild(this._popoverLayer); + if (this.props.position) { this._popoverLayer.style.left = this.props.position.x + 'px'; this._popoverLayer.style.top = this.props.position.y + 'px'; @@ -55,39 +53,29 @@ export default class Popover extends React.Component { if (this.props.color) { this._popoverLayer.style.background = this.props.color; } - if (this.props.fadeIn){ + if (this.props.fadeIn) { this._popoverLayer.className = styles.transition; } - this._popoverWrapper.appendChild(this._popoverLayer); - } - componentWillReceiveProps(nextProps) { - if (nextProps.position) { - this._popoverLayer.style.left = this.props.position.x + 'px'; - this._popoverLayer.style.top = this.props.position.y + 'px'; - } + document.body.addEventListener('click', this._checkExternalClick); } - componentDidMount() { - ReactDOM.render({React.Children.only(this.props.children)}, this._popoverLayer); - document.body.addEventListener('click', this._checkExternalClick); + setPosition(position) { + this._popoverLayer.style.left = position.x + 'px'; + this._popoverLayer.style.top = position.y + 'px'; + this.forceUpdate(); } componentWillUnmount() { + document.body.removeChild(this._popoverWrapper); document.body.removeEventListener('click', this._checkExternalClick); - ReactDOM.unmountComponentAtNode(this._popoverLayer); - this._popoverWrapper.removeChild(this._popoverLayer); - } - - componentWillUpdate(nextProps) { - ReactDOM.render({React.Children.only(nextProps.children)}, this._popoverLayer); } _checkExternalClick(e) { const { contentId } = this.props; const popoverWrapper = contentId ? document.getElementById(contentId) - : this._popoverWrapper; + : this._popoverLayer; const isChromeDropdown = e.target.parentNode.classList.contains('chromeDropdown'); if ( !hasAncestor(e.target, popoverWrapper) && @@ -99,11 +87,12 @@ export default class Popover extends React.Component { } render() { - return null; + return createPortal(this.props.children, this._popoverLayer); } } Popover.contextTypes = { history: PropTypes.object, - router: PropTypes.object + router: PropTypes.object, + currentApp: PropTypes.instanceOf(ParseApp) }; diff --git a/src/components/Popover/Popover.scss b/src/components/Popover/Popover.scss index 7db5a291d6..e4971eff69 100644 --- a/src/components/Popover/Popover.scss +++ b/src/components/Popover/Popover.scss @@ -7,13 +7,14 @@ */ @import 'stylesheets/globals.scss'; -#popover_wrapper, #fixed_wrapper { +.popover_wrapper, .fixed_wrapper { position: absolute; top: 0; left: 0; bottom: 0; right: 0; pointer-events: none; + z-index: 6; & > div { position: absolute; @@ -26,14 +27,19 @@ opacity: 1; } -#popover_wrapper { +.popover_wrapper { position: absolute; } -#fixed_wrapper { +.fixed_wrapper { position: fixed; } +.popoverLayer { + cursor: pointer; +} + + @include keyframes(fade-in) { 0% { opacity: 0; diff --git a/src/components/ProtectedFieldsDialog/ProtectedFieldsDialog.example.js b/src/components/ProtectedFieldsDialog/ProtectedFieldsDialog.example.js new file mode 100644 index 0000000000..6eb0e024d2 --- /dev/null +++ b/src/components/ProtectedFieldsDialog/ProtectedFieldsDialog.example.js @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React from 'react'; +import ProtectedFieldsDialog + from 'components/ProtectedFieldsDialog/ProtectedFieldsDialog.react'; +import Button from 'components/Button/Button.react'; + +export const component = ProtectedFieldsDialog; + + +function validateDemo(text) { + if (text===('*')) { + return Promise.resolve({ entry: '*' , type:'public'}); + } + if (text===('authenticated')) { + return Promise.resolve({ entry: 'authenticated' , type:'auth'}); + } + if (text.startsWith('u')) { + return Promise.resolve({ entry: { id: text, get:() => 'demouser' } , type:'user'}); + } + if (text.startsWith('role:')) { + const roleName = text.substring(5) + return Promise.resolve({ entry: {id:`1d0f${roleName}`, getName:()=>roleName}, type:'role' }); + } + + // if (text.startsWith('f')) { + // return Promise.resolve({ userField: { id: 'i' + ((Math.random() * 10000) | 0)}}); + // } + if (text.startsWith('ptr')) { + return Promise.resolve({ entry: text, type: 'pointer' }); + } + return Promise.reject(); +} + +const columns = { + 'email': { type: 'String',}, + 'password':{ type: 'String', }, + 'ptr_owner':{ type: 'Pointer', targetClass:'_User'}, + 'nickname':{ type: 'String',}, + 'ptr_followers':{ type: 'Array', }, + 'ptr_friends':{ type: 'Array', } +}; + +const userPointers = [ + 'ptr_followers', + 'ptr_owner', + 'ptr_friends' +] + + +class ProtectedFieldsDemo extends React.Component { + constructor() { + super() + this.state = { + show: false + }; + } + + render() { + + return ( +
+
+ ); + } +} + +export const demos = [ + { + render() { + return (); + } + } +]; diff --git a/src/components/ProtectedFieldsDialog/ProtectedFieldsDialog.react.js b/src/components/ProtectedFieldsDialog/ProtectedFieldsDialog.react.js new file mode 100644 index 0000000000..652aaf453f --- /dev/null +++ b/src/components/ProtectedFieldsDialog/ProtectedFieldsDialog.react.js @@ -0,0 +1,521 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import hasAncestor from 'lib/hasAncestor'; +import Button from 'components/Button/Button.react'; +import Autocomplete from 'components/Autocomplete/Autocomplete.react'; +import Icon from 'components/Icon/Icon.react'; +import { Map } from 'immutable'; +import Pill from 'components/Pill/Pill.react'; +import Popover from 'components/Popover/Popover.react'; +import Position from 'lib/Position'; +import React from 'react'; +import ScrollHint from 'components/ScrollHint/ScrollHint.react' +import styles from 'components/ProtectedFieldsDialog/ProtectedFieldsDialog.scss'; +import MultiSelect from 'components/MultiSelect/MultiSelect.react'; +import MultiSelectOption from 'components/MultiSelect/MultiSelectOption.react'; +import TrackVisibility from 'components/TrackVisibility/TrackVisibility.react'; +import { + unselectable, + verticalCenter } from 'stylesheets/base.scss'; + +let origin = new Position(0, 0); +const intersectionMargin = '10px 0px 0px 20px'; + +export default class ProtectedFieldsDialog extends React.Component { + constructor({ protectedFields, columns }) { + super(); + + let keys = Object.keys(protectedFields || {}); + + this.refEntry = React.createRef(null); + this.refTable = React.createRef(null); + this.refScrollHint = React.createRef(null); + + // Intersection observer is used to avoid ugly effe t + // when suggestion are shown whil input field is scrolled out oof viewpoort + const callback = ([entry]) => { + const ratio = entry.intersectionRatio; + const hidden = ratio < 0.92; + // hide suggestions to avoid ugly footer overlap + this.refEntry.current.setHidden(hidden); + // also show indicator when input is not visible + this.refScrollHint.current.toggleActive(hidden); + }; + + this.observer = new IntersectionObserver(callback, { + root: this.refTable.current, + rootMargin: intersectionMargin, + threshold: [0.92] + }); + + this.state = { + entryTypes: undefined, + transitioning: false, + columns: columns, + protectedFields: new Map(protectedFields || {}), // protected fields map + keys, + newEntry: '', + entryError: null, + newKeys: [] + }; + } + + async componentDidMount() { + // validate existing entries, also preserve their types (to render correct pills). + const rows = await Promise.all( + this.state.keys.map(key => this.props.validateEntry(key)) + ); + + let entryTypes = new Map({}); + + for (const { entry, type } of rows) { + let key; + let value = {}; + + if (type === 'user') { + key = entry.id; + value[type] = { + name: entry.get('username'), + id: entry.id + }; + } + + if (type === 'role') { + key = 'role:' + entry.getName(); + value[type] = { + name: entry.getName(), + id: entry.id + }; + } + + if (type === 'public' || type === 'auth' || type === 'pointer') { + key = entry; + value[type] = true; + } + + entryTypes = entryTypes.set(key, value); + } + + this.setState({ entryTypes }); + } + + checkEntry(input) { + if (input === '') { + return; + } + if (this.props.validateEntry) { + this.props.validateEntry(input).then( + ({ type, entry }) => { + let next = { [type]: entry }; + + let key; + let name; + let id; + let newEntry = {}; + + if (next.user || next.role) { + // entry for saving + key = next.user ? next.user.id : 'role:' + next.role.getName(); + + // info for displaying + name = next.user ? next.user.get('username') : next.role.getName(); + id = next.user ? next.user.id : next.role.id; + newEntry[type] = { name, id }; + } else { + key = next.public || next.auth || next.pointer; + newEntry[type] = true; + } + + if (key) { + if ( + this.state.keys.includes(key) || + this.state.newKeys.includes(key) + ) { + return this.setState({ + entryError: 'You already have a row for this object' + }); + } + + let nextKeys = this.state.newKeys.concat([key]); + let nextFields = this.state.protectedFields.set(key, []); + let nextEntryTypes = this.state.entryTypes.set(key, newEntry); + + return this.setState( + { + entryTypes: nextEntryTypes, + protectedFields: nextFields, + newKeys: nextKeys, + entryError: null + }, + this.refEntry.current.resetInput() + ); + } + }, + () => { + if (this.props.enablePointerPermissions) { + this.setState({ + entryError: + 'Role, User or field not found. Enter a valid id, name or column.' + }); + } else { + this.setState({ + entryError: 'Role or User not found. Enter a valid name or id' + }); + } + } + ); + } + } + + deleteRow(key) { + // remove from proectedFields + let protectedFields = this.state.protectedFields.delete(key); + + // also remove from local state + let keys = this.state.keys.filter(k => k !== key); + let newKeys = this.state.newKeys.filter(k => k !== key); + + return this.setState({ + protectedFields, + newKeys, + keys + }); + } + + outputPerms() { + let output = this.state.protectedFields.toObject(); + + return output; + } + + onChange(key, newValue) { + this.setState(state => { + let protectedFields = state.protectedFields; + protectedFields = protectedFields.set(key, newValue); + return { protectedFields }; + }); + } + + /** + * @param {String} key - entity (Public, User, Role, field-pointer) + * @param {Object} schema - object with fields of collection: { [fieldName]: { type: String, targetClass?: String }} + * @param {String[]} selected - fields that are set for entity + * + * Renders Dropdown allowing to pick multiple fields for an entity (row). + */ + renderSelector(key, schema, selected) { + let options = []; + let values = selected || []; + + let entries = Object.entries(schema); + for (let [field, { type, targetClass }] of entries) { + if ( + field === 'objectId' || + field === 'createdAt' || + field === 'updatedAt' || + field === 'ACL' + ) { + continue; + } + + let pillText = type + (targetClass ? `<${targetClass}>` : ''); + + options.push( + + {field} + + + + + ); + } + + let noAvailableFields = options.length === 0; + + if(noAvailableFields){ + options.push( + + {'This class has no fields to protect'} + + ) + } + + const placeholder = 'All fields allowed.'+ (noAvailableFields ? '': ' Click to protect.'); + + return ( +
+ { + this.onChange(key, s); + }} + value={values} + placeHolder={placeholder} + > + {options} + +
+ ); + } + + renderRow(key, columns, types) { + const pill = text => ( + + + + ); + + // types is immutable.js Map + const type = (types && types.get(key)) || {}; + + let label = {key}; + + if (type.role) { + label = ( + +

+ + {'role:'} + {type.role.name} + +

+

+ id: {type.role.id} +

+
+ ); + } + + if (type.user) { + label = ( + +

+ + {type.user.id} + {pill('User')} + +

+

+ username:{' '} + {type.user.name} +

+
+ ); + } + + if (type.public) { + label = ( + +

+ {' '} + * (Public Access) +

+

Applies to all queries

+
+ ); + } + + if (type.auth) { + label = ( + +

Authenticated

+

Applies to any logged user

+
+ ); + } + + if (type.pointer) { + let { type, targetClass } = columns[key.substring(10)]; + let pillText = type + (targetClass ? `<${targetClass}>` : ''); + + label = ( + +

+ userField: + {key.substring(10)} + {pill(pillText)} +

+

Only users pointed to by this field

+
+ ); + } + + let content = null; + if (!this.state.transitioning) { + content = this.renderSelector( + key, + this.state.columns, + this.state.protectedFields.get(key) + ); + } + let trash = null; + if (!this.state.transitioning) { + trash = ( +
+ + + +
+ ); + } + return ( +
+
{label}
+ {content} + {trash} +
+ ); + } + + close(e) { + if (!hasAncestor(e.target, this.node)) { + //In the case where the user clicks on the node, toggle() will handle closing the dropdown. + this.setState({ open: false }); + } + } + + onUserInput(input) { + this.setState({ newEntry: input, entryError: undefined }); + } + + suggestInput(input) { + const userPointers = this.props.userPointers; + + const keys = this.state.keys; + const newKeys = this.state.newKeys; + const allKeys = [...keys, ...newKeys]; + + let availablePointerFields = userPointers + .map(ptr => `userField:${ptr}`) + .filter(ptr => !allKeys.includes(ptr) && ptr.includes(input)); + + let possiblePrefix = ['role:'] + .filter(o => o.startsWith(input) && o.length > input.length) // filter matching prefixes + .concat(...availablePointerFields); // + + // pointer fields that are not applied yet; + let availableFields = []; + + // do not suggest unique rows that are already added; + let uniqueOptions = ['*', 'authenticated'].filter( + key => + !allKeys.includes(key) && (input.length == 0 || key.startsWith(input)) + ); + + availableFields.push(...uniqueOptions); + availableFields.push(...possiblePrefix); + + return availableFields; + } + + onClick(e) { + this.setState( + { + activeSuggestion: 0, + newEntry: e.currentTarget.innerText, + showSuggestions: false + }, + () => { + this.props.onChange(this.state.newEntry); + } + ); + } + + buildLabel(input) { + let label; + if (input.startsWith('userField:')) { + label = 'Name of field with pointer(s) to User'; + } else if (input.startsWith('user:')) { + label = 'Find User by id or name'; + } else if (input.startsWith('role:')) { + label = 'Find Role by id or name'; + } + + return label; + } + + render() { + let classes = [styles.dialog, unselectable]; + + let placeholderText = 'Role/User id/name * or authenticated\u2026'; + + return ( + +
+
{this.props.title}
+
+
+
+ {this.state.keys.map(key => + this.renderRow(key, this.state.columns, this.state.entryTypes) + )} + + {this.state.newKeys.map(key => + this.renderRow(key, this.state.columns, this.state.entryTypes) + )} + +
+ + this.onUserInput(e)} + onSubmit={this.checkEntry.bind(this)} + placeholder={placeholderText} + buildSuggestions={input => this.suggestInput(input)} + buildLabel={input => this.buildLabel(input)} + error={this.state.entryError} + /> + + +
+ {this.state.entryError} +
+
+
+
+
+ +
+
+
+ {this.props.details} +
+
+
+ + ); + } +} diff --git a/src/components/ProtectedFieldsDialog/ProtectedFieldsDialog.scss b/src/components/ProtectedFieldsDialog/ProtectedFieldsDialog.scss new file mode 100644 index 0000000000..5888126fec --- /dev/null +++ b/src/components/ProtectedFieldsDialog/ProtectedFieldsDialog.scss @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +@import "stylesheets/globals.scss"; + +$labelWidth: 440px; +$fieldsWidth: 480px; + +$deleteColWidth: 44px; + +$entryMargin: 20px; +$entryWidth: calc(#{$labelWidth} - #{$entryMargin}* 2); + +$permissionsDialogWidth: calc( + #{$labelWidth} + #{$fieldsWidth} + #{$deleteColWidth} +); + +.dialog { + @include modalAnimation(); + position: absolute; + top: 50%; + left: 50%; + width: $permissionsDialogWidth; + background: white; + border-radius: 5px; + overflow: hidden; + transition: width 0.3s 0.15s ease-out; +} + +.header { + height: 50px; + background: $blue; + position: relative; + color: #ffffff; + line-height: 50px; + font-size: 16px; + text-align: center; +} + +.level { + height: 50px; + width: $permissionsDialogWidth; // width: 658px; + background: #0e69a1; + position: relative; + color: white; + transition: width 0.3s 0.15s ease-out; + + > div { + margin: 0; + position: absolute; + top: 10px; + right: 10px; + } + + > span { + display: inline-block; + font-size: 12; + height: 50px; + line-height: 50px; + padding-left: 20px; + } +} + +.tableWrap { + height: 450px; + overflow-y: auto; + overflow-x: hidden; +} + +.second { + width: #{$fieldsWidth}; +} + +.table { + position: relative; + min-height: 450px; + + .overlay { + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; + background: rgba(0, 0, 40, 0.03); + + &.second { + left: $labelWidth; + } + } +} + +.footer { + position: relative; + height: 51px; + border-top: 1px solid #e3e3ea; + + .details { + font-size: 12px; + padding-left: 20px; + + a { + color: $blue; + } + } + + .actions { + float: right; + padding: 10px 15px; + + a { + margin-left: 10px; + } + } +} + +.multiselect { + display: inline-block; + width: #{$fieldsWidth}; +} + +.row { + display: flex; + min-height: 50px; + height: 100%; + vertical-align: middle; + font-size: 15px; + border-bottom: 1px solid #e3e3ea; + white-space: nowrap; + + &:nth-child(odd) { + background: rgba(14, 105, 161, 0.03); + } +} + +.row.public { + background: rgba(22, 156, 238, 0.18); + border-bottom: 1px solid #0e69a1; + color: $blue; +} + +.label { + display: inline-flex; + width: $labelWidth; + padding: 0 20px; + flex-direction: column; + align-self: center; +} + +.entry { + height: 30px; + width: $entryWidth; + border: 1px solid $mainTextColor; + border-radius: 5px; + font-size: 14px; + outline: none; + padding: 0 6px; + margin: 10px $entryMargin; + vertical-align: top; +} + +.error { + border-color: $red; + color: $red; + overflow: hidden; + white-space: normal; +} + +.delete { + display: inline-block; + vertical-align: top; + width: 32px; + height: 50px; + padding-top: 15px; + text-align: right; + + svg { + vertical-align: top; + cursor: pointer; + fill: #c1c7cd; + + &:hover { + fill: $red; + } + } +} + +.pillHolder { + max-width: 200px; + overflow: hidden; + display: inline-flex; + align-self: center; + padding-left: 4px; +} + +.pillType { + width: auto; + display: inline-flex; + height: 20; + padding: 2px 8px 0 8px; +} + +.multiselect { + .pillType { + visibility: collapse; + display: none; + top: 5px; + } +} + +.hint { + color: $secondaryTextColor; + font-size: 0.8em; +} +.selectable { + user-select: text; +} +.prefix { + color: #0e69a1; +} + +.suggestions { + margin-left: -19px; + width: $entryWidth; + margin-top: -24px; +} + +.entry input { + width: $entryWidth !important; + padding: 0 6px; + margin: 10px 20px; +} + +@include modalKeyframes(); diff --git a/src/components/ScrollHint/ScrollHint.react.js b/src/components/ScrollHint/ScrollHint.react.js new file mode 100644 index 0000000000..8933679914 --- /dev/null +++ b/src/components/ScrollHint/ScrollHint.react.js @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ + import React from 'react' + import styles from 'components/ScrollHint/ScrollHint.scss' + + export default class ScrollHint extends React.Component { + constructor() { + super(); + this.state = { active: false }; + } + + toggle(active) { + this.setState({ active }); + } + + render() { + const { active } = this.state; + + const classes = [ + styles.scrollHint, + active ? styles.active: undefined + ].join(' '); + + return
; + } + } diff --git a/src/components/ScrollHint/ScrollHint.scss b/src/components/ScrollHint/ScrollHint.scss new file mode 100644 index 0000000000..96ffb54834 --- /dev/null +++ b/src/components/ScrollHint/ScrollHint.scss @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +.scrollHint.active::before{ + content: '╲╱'; + color: rgb(116, 214, 249); + position: absolute; + opacity: 0.8; + text-shadow: 0 0 .5rem rgba(0,0,0,0.5); + width: 6rem; + font-size: 1rem; + height: 2rem; + line-height: 2rem; + text-align: center; + bottom: 2rem; + margin-left: -3rem; + left: 50%; + animation: bounce 1s ease infinite; + } + + @keyframes bounce { + 50% { + transform: translateY(-50%); + } + 100% { + transform: translateY(0); + } + } \ No newline at end of file diff --git a/src/components/SuggestionsList/SuggestionsList.react.js b/src/components/SuggestionsList/SuggestionsList.react.js new file mode 100644 index 0000000000..448236ddbb --- /dev/null +++ b/src/components/SuggestionsList/SuggestionsList.react.js @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import Popover from 'components/Popover/Popover.react'; +import React from 'react'; +import styles from 'components/SuggestionsList/SuggestionsList.scss'; +import Position from 'lib/Position'; + +export default class Suggestion extends React.Component { + constructor() { + super(); + this.state = { + activeSuggestion: 0, + open: false, + position: null + }; + + this.popoverRef = React.createRef(null); + + this.handleScroll = () => { + let newPosition = this.props.fixed + ? Position.inWindow(this.node) + : Position.inDocument(this.node); + newPosition.y += this.node.offsetHeight; + if (this.popoverRef.current) { + this.popoverRef.current.setPosition(newPosition); + } + }; + } + + toggle() { + this.setPosition(); + this.setState({ open: !this.state.open }); + } + + setPosition(position) { + this.popoverRef.current && this.popoverRef.current.setPosition(position); + } + + close() { + this.setState({ open: false }); + } + + render() { + const { + position, + onExternalClick, + suggestions, + suggestionsStyle, + activeSuggestion, + onClick} = this.props; + + return ( + +
    + {suggestions.map((suggestion, index) => { + let className; + if (index === activeSuggestion) { + className = styles.active; + } + return ( +
  • + {suggestion} +
  • + ); + })} +
+
+ ); + } +} diff --git a/src/components/SuggestionsList/SuggestionsList.scss b/src/components/SuggestionsList/SuggestionsList.scss new file mode 100644 index 0000000000..1314d0ba8f --- /dev/null +++ b/src/components/SuggestionsList/SuggestionsList.scss @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +@import 'stylesheets/globals.scss'; + +.suggestions { + border: 1px solid $mainTextColor; + border-top-width: 0; + list-style: none; + max-height: 143px; + overflow-y: auto; + -moz-box-shadow: 0px 0px 0px #666, 0px 4px 8px #666; + -webkit-box-shadow: 0px 0px 0px #666, 0px 4px 8px #666; + box-shadow:0px 0px 0px #666, 0px 4px 8px #666; +} + +.suggestions li { + background: white; + padding-left: 10px; + font-family:"Open Sans", sans-serif; +} + +.active, +.suggestions li:hover { + color: #0e69a1; + cursor: pointer; + font-weight: 500; +} + +.suggestions li:not(:last-of-type) { + border-bottom: 1px solid #999; +} \ No newline at end of file diff --git a/src/components/TrackVisibility/TrackVisibility.example.js b/src/components/TrackVisibility/TrackVisibility.example.js new file mode 100644 index 0000000000..7cea63daf6 --- /dev/null +++ b/src/components/TrackVisibility/TrackVisibility.example.js @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React from 'react'; +import TrackVisibility from 'components/TrackVisibility/TrackVisibility.react'; + +export const component = TrackVisibility; + +class DemoTrackVisibility extends React.Component { + constructor() { + super(); + + this.ref = React.createRef(null); + + ///[0.00...1.00] + const thresholds = Array(101) + .fill() + .map((v, i) => Math.round(i) / 100); + + const callback = ([entry]) => { + const ratio = entry.intersectionRatio; + this.setState({ visibility: Math.round(ratio * 100) }); + }; + + this.observer = new IntersectionObserver(callback, { + root: this.ref.current, + threshold: thresholds + }); + + this.state = { + visibility: 0 + }; + } + + render() { + + return ( + +
{'Yellow block is ' + this.state.visibility + '% visible'}
+
+
+ {'Scroll down'} +
+ +
+ +
+ {'Scroll up'} +
+
+ + ); + } +} +export const demos = [ + { + render: () => ( + + ) + } +]; diff --git a/src/components/TrackVisibility/TrackVisibility.react.js b/src/components/TrackVisibility/TrackVisibility.react.js new file mode 100644 index 0000000000..66d2a53d7c --- /dev/null +++ b/src/components/TrackVisibility/TrackVisibility.react.js @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React, { useRef, useEffect } from 'react'; + +export default function TrackVisibility(props) { + const refContainer = useRef(null); + + useEffect(() => { + props.observer.observe(refContainer.current); + return () => { + props.observer.disconnect(); + }; + }, [props.observer]); + + return
{props.children}
; +} diff --git a/src/dashboard/Data/Browser/Browser.react.js b/src/dashboard/Data/Browser/Browser.react.js index 25a282ab05..4bbafe93db 100644 --- a/src/dashboard/Data/Browser/Browser.react.js +++ b/src/dashboard/Data/Browser/Browser.react.js @@ -120,6 +120,7 @@ class Browser extends DashboardView { this.showEditRowDialog = this.showEditRowDialog.bind(this); this.closeEditRowDialog = this.closeEditRowDialog.bind(this); this.handleShowAcl = this.handleShowAcl.bind(this); + this.onDialogToggle = this.onDialogToggle.bind(this); } componentWillMount() { @@ -718,7 +719,8 @@ class Browser extends DashboardView { this.state.showAttachRowsDialog || this.state.showAttachSelectedRowsDialog || this.state.showCloneSelectedRowsDialog || - this.state.showEditRowDialog + this.state.showEditRowDialog || + this.state.showPermissionsDialog ); } @@ -931,6 +933,11 @@ class Browser extends DashboardView { this.refs.dataBrowser.setCurrent({ row, col }); } + // skips key controls handling when dialog is opened + onDialogToggle(opened){ + this.setState({showPermissionsDialog: opened}); + } + renderContent() { let browser = null; let className = this.props.params.className; @@ -999,6 +1006,7 @@ class Browser extends DashboardView { onAttachSelectedRows={this.showAttachSelectedRowsDialog} onCloneSelectedRows={this.showCloneSelectedRowsDialog} onEditSelectedRow={this.showEditRowDialog} + onEditPermissions={this.onDialogToggle} columns={columns} className={className} diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index ced295f249..017ac7573b 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -5,18 +5,18 @@ * This source code is licensed under the license found in the LICENSE file in * the root directory of this source tree. */ -import BrowserFilter from 'components/BrowserFilter/BrowserFilter.react'; -import BrowserMenu from 'components/BrowserMenu/BrowserMenu.react'; -import ColumnsConfiguration - from 'components/ColumnsConfiguration/ColumnsConfiguration.react'; -import Icon from 'components/Icon/Icon.react'; -import MenuItem from 'components/BrowserMenu/MenuItem.react'; -import prettyNumber from 'lib/prettyNumber'; -import React from 'react'; -import SecurityDialog from 'dashboard/Data/Browser/SecurityDialog.react'; -import Separator from 'components/BrowserMenu/Separator.react'; -import styles from 'dashboard/Data/Browser/Browser.scss'; -import Toolbar from 'components/Toolbar/Toolbar.react'; +import BrowserFilter from 'components/BrowserFilter/BrowserFilter.react'; +import BrowserMenu from 'components/BrowserMenu/BrowserMenu.react'; +import Icon from 'components/Icon/Icon.react'; +import MenuItem from 'components/BrowserMenu/MenuItem.react'; +import prettyNumber from 'lib/prettyNumber'; +import React, { useRef } from 'react'; +import Separator from 'components/BrowserMenu/Separator.react'; +import styles from 'dashboard/Data/Browser/Browser.scss'; +import Toolbar from 'components/Toolbar/Toolbar.react'; +import SecurityDialog from 'dashboard/Data/Browser/SecurityDialog.react'; +import ColumnsConfiguration from 'components/ColumnsConfiguration/ColumnsConfiguration.react' +import SecureFieldsDialog from 'dashboard/Data/Browser/SecureFieldsDialog.react'; let BrowserToolbar = ({ className, @@ -43,6 +43,7 @@ let BrowserToolbar = ({ onDropClass, onChangeCLP, onRefresh, + onEditPermissions, hidePerms, isUnique, uniqueField, @@ -152,6 +153,7 @@ let BrowserToolbar = ({ onClick = null; } + const columns = {}; const userPointers = []; const schemaSimplifiedData = {}; const classSchema = schema.data.get('classes').get(classNameForEditors); @@ -162,15 +164,23 @@ let BrowserToolbar = ({ targetClass, }; + columns[col] = { type, targetClass }; + if (col === 'objectId' || isUnique && col !== uniqueField) { return; } - if (targetClass === '_User') { + if ((type ==='Pointer' && targetClass === '_User') || type === 'Array' ) { userPointers.push(col); } }); } + let clpDialogRef = useRef(null); + let protectedDialogRef = useRef(null); + + const showCLP = ()=> clpDialogRef.current.handleOpen(); + const showProtected = () => protectedDialogRef.current.handleOpen(); + return ( + order={order} + />
- + Refresh
@@ -202,16 +213,55 @@ let BrowserToolbar = ({ filters={filters} onChange={onFilterChange} className={classNameForEditors} - blacklistedFilters={onAddRow ? [] : ['unique']} /> + blacklistedFilters={onAddRow ? [] : ['unique']} + /> {onAddRow &&
} - {perms && enableSecurityDialog ? + ) : ( +