mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
135
app/client/aclui/ACLColumnList.ts
Normal file
135
app/client/aclui/ACLColumnList.ts
Normal 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;
|
||||
}
|
||||
`);
|
||||
125
app/client/aclui/ACLFormulaEditor.ts
Normal file
125
app/client/aclui/ACLFormulaEditor.ts
Normal 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;
|
||||
`);
|
||||
37
app/client/aclui/ACLSelect.ts
Normal file
37
app/client/aclui/ACLSelect.ts
Normal 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;
|
||||
}
|
||||
`);
|
||||
1175
app/client/aclui/AccessRules.ts
Normal file
1175
app/client/aclui/AccessRules.ts
Normal file
File diff suppressed because it is too large
Load Diff
175
app/client/aclui/PermissionsWidget.ts
Normal file
175
app/client/aclui/PermissionsWidget.ts
Normal 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;
|
||||
`);
|
||||
Reference in New Issue
Block a user