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/D2697pull/3/head
parent
4ad84f44a7
commit
d6d1eb217f
@ -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;
|
||||
}
|
||||
`);
|
@ -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;
|
||||
`);
|
@ -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;
|
||||
}
|
||||
`);
|
@ -0,0 +1,189 @@
|
||||
import * as ace from 'brace';
|
||||
|
||||
// Suggestion may be a string, or a tuple [funcname, argSpec, isGrist], where:
|
||||
// - funcname (e.g. "DATEADD") will be auto-completed with "(", AND linked to Grist
|
||||
// documentation.
|
||||
// - argSpec (e.g. "(start_date, days=0, ...)") is to be shown as autocomplete caption.
|
||||
// - isGrist determines whether to tag this suggestion as "grist" or "python".
|
||||
export type ISuggestion = string | [string, string, boolean];
|
||||
|
||||
export interface ICompletionOptions {
|
||||
getSuggestions(prefix: string): Promise<ISuggestion[]>;
|
||||
}
|
||||
|
||||
const completionOptions = new WeakMap<ace.Editor, ICompletionOptions>();
|
||||
|
||||
export function setupAceEditorCompletions(editor: ace.Editor, options: ICompletionOptions) {
|
||||
initCustomCompleter();
|
||||
completionOptions.set(editor, options);
|
||||
|
||||
// Create Autocomplete object at this point so we can turn autoSelect off.
|
||||
// There doesn't seem to be any way to get ace to respect autoSelect otherwise.
|
||||
// It is important for autoSelect to be off so that hitting enter doesn't automatically
|
||||
// use a suggestion, a change of behavior that doesn't seem particularly desirable and
|
||||
// which also breaks several existing tests.
|
||||
const {Autocomplete} = ace.acequire('ace/autocomplete'); // lives in brace/ext/language_tools
|
||||
const completer = new Autocomplete();
|
||||
completer.autoSelect = false;
|
||||
(editor as any).completer = completer;
|
||||
|
||||
aceCompleterAddHelpLinks(completer);
|
||||
|
||||
// Explicitly destroy the auto-completer on disposal, since it doesn't not remove the element
|
||||
// it adds to body even when it detaches itself. Ace's AutoCompleter doesn't expose any
|
||||
// interface for this, so this takes some hacking. (One reason for this is that Ace seems to
|
||||
// expect that a single AutoCompleter would be used for all editor instances.)
|
||||
editor.on('destroy', () => {
|
||||
if (completer.editor) {
|
||||
completer.detach();
|
||||
}
|
||||
if (completer.popup) {
|
||||
completer.popup.destroy(); // This is not enough, but seems relevant to call.
|
||||
completer.popup.container.remove(); // Removes the element from DOM.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _initialized = false;
|
||||
function initCustomCompleter() {
|
||||
if (_initialized) { return; }
|
||||
_initialized = true;
|
||||
|
||||
// Add some autocompletion with partial access to document
|
||||
const aceLanguageTools = ace.acequire('ace/ext/language_tools');
|
||||
aceLanguageTools.setCompleters([]);
|
||||
aceLanguageTools.addCompleter({
|
||||
// Default regexp stops at periods, which doesn't let autocomplete
|
||||
// work on members. So we expand it to include periods.
|
||||
// We also include $, which grist uses for column names.
|
||||
identifierRegexps: [/[a-zA-Z_0-9.$\u00A2-\uFFFF]/],
|
||||
|
||||
// For autocompletion we ship text to the sandbox and run standard completion there.
|
||||
async getCompletions(editor: ace.Editor, session: ace.IEditSession, pos: number, prefix: string, callback: any) {
|
||||
const options = completionOptions.get(editor);
|
||||
if (!options || prefix.length === 0) { callback(null, []); return; }
|
||||
const suggestions = await options.getSuggestions(prefix);
|
||||
// ACE autocompletions are very poorly documented. This is somewhat helpful:
|
||||
// https://prog.world/implementing-code-completion-in-ace-editor/
|
||||
callback(null, suggestions.map(suggestion => {
|
||||
if (Array.isArray(suggestion)) {
|
||||
const [funcname, argSpec, isGrist] = suggestion;
|
||||
const meta = isGrist ? 'grist' : 'python';
|
||||
return {value: funcname + '(', caption: funcname + argSpec, score: 1, meta, funcname};
|
||||
} else {
|
||||
return {value: suggestion, score: 1, meta: "python"};
|
||||
}
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When autocompleting a known function (with funcname received from the server call), turn the
|
||||
* function name into a link to Grist documentation.
|
||||
*
|
||||
* This is only applied for items returned from getCompletions() that include a our custom
|
||||
* `funcname` attribute.
|
||||
*
|
||||
* ACE autocomplete is poorly documented, and poorly customizable, so this is accomplished by
|
||||
* monkey-patching it. Further, the only text styling is done via styled tokens, but we can style
|
||||
* them to look like links, and handle clicks to open the destination URL.
|
||||
*
|
||||
* This implementation relies a lot on the details of the implementation in
|
||||
* node_modules/brace/ext/language_tools.js. Updates to brace module may easily break it.
|
||||
*/
|
||||
function aceCompleterAddHelpLinks(completer: any) {
|
||||
// Replace the $init function in order to intercept the creation of the autocomplete popup.
|
||||
const init = completer.$init;
|
||||
completer.$init = function() {
|
||||
const popup = init.apply(this, arguments);
|
||||
customizeAceCompleterPopup(this, popup);
|
||||
return popup;
|
||||
};
|
||||
}
|
||||
|
||||
function customizeAceCompleterPopup(completer: any, popup: any) {
|
||||
// Replace the $tokenizeRow function to produce customized tokens to style the link part.
|
||||
const origTokenize = popup.session.bgTokenizer.$tokenizeRow;
|
||||
popup.session.bgTokenizer.$tokenizeRow = function(row: any) {
|
||||
const tokens = origTokenize(row);
|
||||
return retokenizeAceCompleterRow(popup.data[row], tokens);
|
||||
};
|
||||
|
||||
// Replace the click handler with one that handles link clicks.
|
||||
popup.removeAllListeners("click");
|
||||
popup.on("click", function(e: any) {
|
||||
if (!maybeAceCompleterLinkClick(e.domEvent)) {
|
||||
completer.insertMatch();
|
||||
}
|
||||
e.stop();
|
||||
});
|
||||
}
|
||||
|
||||
interface TokenInfo extends ace.TokenInfo {
|
||||
type: string;
|
||||
}
|
||||
|
||||
function retokenizeAceCompleterRow(rowData: any, tokens: TokenInfo[]): TokenInfo[] {
|
||||
if (!rowData.funcname) {
|
||||
// Not a special completion, pass through the result of ACE's original tokenizing.
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ACE's original tokenizer splits rowData.caption into tokens to highlight matching portions.
|
||||
// We jump in, and further divide the tokens so that those that form the link get an extra CSS
|
||||
// class. ACE's will turn token.type into CSS classes by splitting the type on "." and prefixing
|
||||
// the resulting substrings with "ace_".
|
||||
|
||||
// Funcname may be the recognized name itself (e.g. "UPPER"), or a method (like
|
||||
// "Table1.lookupOne"), in which case only the portion after the dot is the recognized name.
|
||||
|
||||
// Figure out the portion that should be linkified.
|
||||
const dot = rowData.funcname.lastIndexOf(".");
|
||||
const linkStart = dot < 0 ? 0 : dot + 1;
|
||||
const linkEnd = rowData.funcname.length;
|
||||
|
||||
const newTokens = [];
|
||||
|
||||
// Include into new tokens a special token that will be hidden, but include the link URL. On
|
||||
// click, we find it to know what URL to open.
|
||||
const href = 'https://support.getgrist.com/functions/#' +
|
||||
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
|
||||
newTokens.push({value: href, type: 'grist_link_hidden'});
|
||||
|
||||
// Go through tokens, splitting them if needed, and modifying those that form the link part.
|
||||
let position = 0;
|
||||
for (const t of tokens) {
|
||||
// lStart/lEnd are indices of the link within the token, possibly negative.
|
||||
const lStart = linkStart - position, lEnd = linkEnd - position;
|
||||
if (lStart > 0) {
|
||||
const beforeLink = t.value.slice(0, lStart);
|
||||
newTokens.push({value: beforeLink, type: t.type});
|
||||
}
|
||||
if (lEnd > 0) {
|
||||
const inLink = t.value.slice(Math.max(0, lStart), lEnd);
|
||||
const newType = t.type + (t.type ? '.' : '') + 'grist_link';
|
||||
newTokens.push({value: inLink, type: newType});
|
||||
}
|
||||
if (lEnd < t.value.length) {
|
||||
const afterLink = t.value.slice(lEnd);
|
||||
newTokens.push({value: afterLink, type: t.type});
|
||||
}
|
||||
position += t.value.length;
|
||||
}
|
||||
return newTokens;
|
||||
}
|
||||
|
||||
// On any click on AceCompleter popup, we check if we happened to click .ace_grist_link class. If
|
||||
// so, we should be able to find the URL and open another window to it.
|
||||
function maybeAceCompleterLinkClick(domEvent: Event) {
|
||||
const tgt = domEvent.target as HTMLElement;
|
||||
if (tgt && tgt.matches('.ace_grist_link')) {
|
||||
const dest = tgt.parentElement?.querySelector('.ace_grist_link_hidden');
|
||||
if (dest) {
|
||||
window.open(dest.textContent!, "_blank");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
Loading…
Reference in new issue