(core) One more phase of ACL UI revision.

Summary:
- Add ACLColumnList widget for a list of column IDs.
- Replace autocomplete widgets with simpler dropdowns.
- Add select dropdown for the Attribute of UserAttribute rules.
- Switch formula to use ACE editor.
- Factor out customized completion logic from AceEditor.js into a separate file.
- Implement completions for ACL formulas.
- Collect ACL UI files in app/client/aclui

Test Plan: Updated test case, some behavior (like formula autocomplete) only tested manually.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2697
This commit is contained in:
Dmitry S
2020-12-22 22:18:07 -05:00
parent 4ad84f44a7
commit d6d1eb217f
9 changed files with 597 additions and 223 deletions

View File

@@ -0,0 +1,135 @@
/**
* Implements a widget for showing and editing a list of colIds. It offers a select dropdown to
* add a new column, and allows removing already-added columns.
*/
import {aclSelect, cssSelect} from 'app/client/aclui/ACLSelect';
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Computed, dom, Observable, styled} from 'grainjs';
export function aclColumnList(colIds: Observable<string[]>, validColIds: string[]) {
// Define some helpers functions.
function removeColId(colId: string) {
colIds.set(colIds.get().filter(c => (c !== colId)));
}
function addColId(colId: string) {
colIds.set([...colIds.get(), colId]);
selectBox.focus();
}
function onFocus(ev: FocusEvent) {
editing.set(true);
// Focus the select box, except when focus just moved from it, e.g. after Shift-Tab.
if (ev.relatedTarget !== selectBox) {
selectBox.focus();
}
}
function onBlur() {
if (!selectBox.matches('.weasel-popup-open') && colIds.get().length > 0) {
editing.set(false);
}
}
// The observable for the selected element is a Computed, with a callback for being set, which
// adds the selected colId to the list.
const newColId = Computed.create(null, (use) => '')
.onWrite((value) => { setTimeout(() => addColId(value), 0); });
// We don't allow adding the same column twice, so for the select dropdown build a list of
// unused colIds.
const unusedColIds = Computed.create(null, colIds, (use, _colIds) => {
const used = new Set(_colIds);
return validColIds.filter(c => !used.has(c));
});
// The "editing" observable determines which of two states is active: to show or to edit.
const editing = Observable.create(null, !colIds.get().length);
let selectBox: HTMLElement;
return cssColListWidget({tabIndex: '0'},
dom.autoDispose(unusedColIds),
cssColListWidget.cls('-editing', editing),
dom.on('focus', onFocus),
dom.forEach(colIds, colId =>
cssColItem(
cssColId(colId),
cssColItemIcon(icon('CrossSmall'),
dom.on('click', () => removeColId(colId))
)
)
),
cssNewColItem(
dom.update(
selectBox = aclSelect(newColId, unusedColIds, {defaultLabel: '[Add Column]'}),
cssSelect.cls('-active'),
dom.on('blur', onBlur),
dom.onKeyDown({Escape: onBlur}),
// If starting out in edit mode, focus the select box.
(editing.get() ? (elem) => { setTimeout(() => elem.focus(), 0); } : null)
),
)
);
}
const cssColListWidget = styled('div', `
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
outline: none;
margin: 6px 8px;
cursor: pointer;
border-radius: 4px;
border: 1px solid transparent;
&:not(&-editing):hover {
border: 1px solid ${colors.darkGrey};
}
`);
const cssColItem = styled('div', `
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 3px;
padding-left: 6px;
padding-right: 2px;
.${cssColListWidget.className}-editing & {
background-color: ${colors.mediumGreyOpaque};
}
`);
const cssColId = styled('div', `
flex: auto;
height: 24px;
line-height: 24px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
`);
const cssNewColItem = styled('div', `
margin-top: 2px;
display: none;
.${cssColListWidget.className}-editing & {
display: flex;
}
`);
const cssColItemIcon = styled('div', `
flex: none;
height: 16px;
width: 16px;
border-radius: 16px;
display: none;
cursor: default;
--icon-color: ${colors.slate};
&:hover {
background-color: ${colors.slate};
--icon-color: ${colors.light};
}
.${cssColListWidget.className}-editing & {
display: flex;
}
`);

View File

@@ -0,0 +1,125 @@
import {setupAceEditorCompletions} from 'app/client/components/AceEditorCompletions';
import {colors} from 'app/client/ui2018/cssVars';
import * as ace from 'brace';
import {dom, DomArg, Observable, styled} from 'grainjs';
export interface ACLFormulaOptions {
initialValue: string;
readOnly: boolean;
placeholder: DomArg;
setValue: (value: string) => void;
getSuggestions: (prefix: string) => string[];
}
export function aclFormulaEditor(options: ACLFormulaOptions) {
// Create an element and an editor within it.
const editorElem = dom('div');
const editor: ace.Editor = ace.edit(editorElem);
// Set various editor options.
editor.setTheme('ace/theme/chrome');
editor.setOptions({enableLiveAutocompletion: true});
editor.renderer.setShowGutter(false); // Default line numbers to hidden
editor.renderer.setPadding(0);
editor.$blockScrolling = Infinity;
editor.setReadOnly(options.readOnly);
editor.setFontSize('12');
editor.setHighlightActiveLine(false);
const session = editor.getSession();
session.setMode('ace/mode/python');
session.setTabSize(2);
session.setUseWrapMode(false);
// Implement placeholder text since the version of ACE we use doesn't support one.
const showPlaceholder = Observable.create(null, !options.initialValue.length);
editor.renderer.scroller.appendChild(
cssAcePlaceholder(dom.show(showPlaceholder), options.placeholder)
);
editor.on("change", () => showPlaceholder.set(!editor.getValue().length));
async function getSuggestions(prefix: string) {
return [
// The few Python keywords and constants we support.
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
// The common variables.
'user', 'rec',
// Other completions that depend on doc schema or other rules.
...options.getSuggestions(prefix),
];
}
setupAceEditorCompletions(editor, {getSuggestions});
// Save on blur.
editor.on("blur", () => options.setValue(editor.getValue()));
// Blur (and save) on Enter key.
editor.commands.addCommand({
name: 'onEnter',
bindKey: {win: 'Enter', mac: 'Enter'},
exec: () => editor.blur(),
});
// Disable Tab/Shift+Tab commands to restore their regular behavior.
(editor.commands as any).removeCommands(['indent', 'outdent']);
function resize() {
if (editor.renderer.lineHeight === 0) {
// Reschedule the resize, since it's not ready yet. Seems to happen occasionally.
setTimeout(resize, 50);
}
editorElem.style.width = 'auto';
editorElem.style.height = (Math.max(1, session.getScreenLength()) * editor.renderer.lineHeight) + 'px';
editor.resize();
}
// Set the editor's initial value.
editor.setValue(options.initialValue);
// Resize the editor on change, and initially once it's attached to the page.
editor.on('change', resize);
setTimeout(resize, 0);
return cssConditionInputAce(
cssConditionInputAce.cls('-disabled', options.readOnly),
dom.onDispose(() => editor.destroy()),
editorElem,
);
}
const cssConditionInputAce = styled('div', `
width: 100%;
min-height: 28px;
padding: 5px 6px 5px 6px;
border-radius: 3px;
border: 1px solid transparent;
cursor: pointer;
&:hover {
border: 1px solid ${colors.darkGrey};
}
&:not(&-disabled):focus-within {
box-shadow: inset 0 0 0 1px ${colors.cursor};
border-color: ${colors.cursor};
}
&:not(:focus-within) .ace_scroller, &-disabled .ace_scroller {
cursor: unset;
}
&-disabled, &-disabled:hover {
background-color: ${colors.mediumGreyOpaque};
box-shadow: unset;
border-color: transparent;
}
&-disabled .ace-chrome {
background-color: ${colors.mediumGreyOpaque};
}
& .ace_marker-layer, & .ace_cursor-layer {
display: none;
}
&:not(&-disabled) .ace_focus .ace_marker-layer, &:not(&-disabled) .ace_focus .ace_cursor-layer {
display: block;
}
`);
const cssAcePlaceholder = styled('div', `
opacity: 0.5;
`);

View File

@@ -0,0 +1,37 @@
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {IOption, select} from 'app/client/ui2018/menus';
import {MaybeObsArray, Observable, styled} from 'grainjs';
import * as weasel from 'popweasel';
/**
* A styled version of select() from ui2018/menus, for use in the AccessRules page.
*/
export function aclSelect<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,
options: weasel.ISelectUserOptions = {}) {
return cssSelect(obs, optionArray, {buttonArrow: cssSelectArrow('Collapse'), ...options});
}
export const cssSelect = styled(select, `
height: 28px;
width: 100%;
border: 1px solid transparent;
cursor: pointer;
&:hover, &:focus, &.weasel-popup-open, &-active {
border: 1px solid ${colors.darkGrey};
box-shadow: none;
}
`);
const cssSelectCls = cssSelect.className;
const cssSelectArrow = styled(icon, `
margin: 0 2px;
pointer-events: none;
display: none;
.${cssSelectCls}:hover &, .${cssSelectCls}:focus &, .weasel-popup-open &, .${cssSelectCls}-active & {
display: flex;
}
`);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
/**
* Implements a widget showing 3-state boxes for permissions
* (for Allow / Deny / Pass-Through).
*/
import {colors, testId} from 'app/client/ui2018/cssVars';
import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {menu, menuIcon, menuItem} from 'app/client/ui2018/menus';
import {PartialPermissionSet, PartialPermissionValue} from 'app/common/ACLPermissions';
import {ALL_PERMISSION_PROPS, emptyPermissionSet} from 'app/common/ACLPermissions';
import {capitalize} from 'app/common/gutil';
import {dom, DomElementArg, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
// One of the strings 'read', 'update', etc.
export type PermissionKey = keyof PartialPermissionSet;
/**
* Renders a box for each of availableBits, and a dropdown with a description and some shortcuts.
*/
export function permissionsWidget(
availableBits: PermissionKey[],
pset: Observable<PartialPermissionSet>,
options: {disabled: boolean},
...args: DomElementArg[]
) {
// These are the permission sets available to set via the dropdown.
const empty: PartialPermissionSet = emptyPermissionSet();
const allowAll: PartialPermissionSet = makePermissionSet(availableBits, () => 'allow');
const denyAll: PartialPermissionSet = makePermissionSet(availableBits, () => 'deny');
const readOnly: PartialPermissionSet = makePermissionSet(availableBits, (b) => b === 'read' ? 'allow' : 'deny');
return cssPermissions(
dom.forEach(availableBits, (bit) => {
return cssBit(
bit.slice(0, 1).toUpperCase(), // Show the first letter of the property (e.g. "R" for "read")
cssBit.cls((use) => '-' + use(pset)[bit]), // -allow, -deny class suffixes.
dom.attr('title', (use) => capitalize(`${use(pset)[bit]} ${bit}`.trim())), // Explanation on hover
dom.cls('disabled', options.disabled),
// Cycle the bit's value on click, unless disabled.
(options.disabled ? null :
dom.on('click', () => pset.set({...pset.get(), [bit]: next(pset.get()[bit])}))
)
);
}),
cssIconButton(icon('Dropdown'), testId('permissions-dropdown'), menu(() => {
// Show a disabled "Custom" menu item if the permission set isn't a recognized one, for
// information purposes.
const isCustom = [allowAll, denyAll, readOnly, empty].every(ps => !isEqual(ps, pset.get()));
return [
(isCustom ?
cssMenuItem(() => null, dom.cls('disabled'), menuIcon('Tick'),
cssMenuItemContent(
'Custom',
cssMenuItemDetails(dom.text((use) => psetDescription(use(pset))))
),
) :
null
),
// If the set matches any recognized pattern, mark that item with a tick (checkmark).
cssMenuItem(() => pset.set(allowAll), tick(isEqual(pset.get(), allowAll)), 'Allow All',
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => pset.set(denyAll), tick(isEqual(pset.get(), denyAll)), 'Deny All',
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => pset.set(readOnly), tick(isEqual(pset.get(), readOnly)), 'Read Only',
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => pset.set(empty),
// For the empty permission set, it seems clearer to describe it as "No Effect", but to
// all it "Clear" when offering to the user as the action.
isEqual(pset.get(), empty) ? [tick(true), 'No Effect'] : [tick(false), 'Clear'],
dom.cls('disabled', options.disabled),
),
];
})),
...args
);
}
function next(pvalue: PartialPermissionValue): PartialPermissionValue {
switch (pvalue) {
case 'allow': return '';
case 'deny': return 'allow';
}
return 'deny';
}
// Helper to build up permission sets.
function makePermissionSet(bits: PermissionKey[], makeValue: (bit: PermissionKey) => PartialPermissionValue) {
const pset = emptyPermissionSet();
for (const bit of bits) {
pset[bit] = makeValue(bit);
}
return pset;
}
// Helper for a tick (checkmark) icon, replacing it with an equialent space when not shown.
function tick(show: boolean) {
return show ? menuIcon('Tick') : cssMenuIconSpace();
}
// Human-readable summary of the permission set. E.g. "Allow Read. Deny Update, Create.".
function psetDescription(permissionSet: PartialPermissionSet): string {
const allow: string[] = [];
const deny: string[] = [];
for (const prop of ALL_PERMISSION_PROPS) {
const value = permissionSet[prop];
if (value === "allow") {
allow.push(capitalize(prop));
} else if (value === "deny") {
deny.push(capitalize(prop));
}
}
const parts: string[] = [];
if (allow.length) { parts.push(`Allow ${allow.join(", ")}.`); }
if (deny.length) { parts.push(`Deny ${deny.join(", ")}.`); }
return parts.join(' ');
}
const cssPermissions = styled('div', `
display: flex;
gap: 4px;
`);
const cssBit = styled('div', `
flex: none;
height: 24px;
width: 24px;
border-radius: 2px;
font-size: 13px;
font-weight: 500;
border: 1px dashed ${colors.darkGrey};
color: ${colors.darkGrey};
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&-allow {
background-color: ${colors.lightGreen};
border: 1px solid ${colors.lightGreen};
color: white;
}
&-deny {
background-image: linear-gradient(-45deg, ${colors.error} 14px, white 15px 16px, ${colors.error} 16px);
border: 1px solid ${colors.error};
color: white;
}
&.disabled {
opacity: 0.5;
}
`);
const cssMenuIconSpace = styled('div', `
width: 24px;
`);
// Don't make disabled item too hard to see here.
const cssMenuItem = styled(menuItem, `
align-items: start;
&.disabled {
opacity: unset;
}
`);
const cssMenuItemContent = styled('div', `
display: flex;
flex-direction: column;
`);
const cssMenuItemDetails = styled('div', `
font-size: 12px;
`);