mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add new UI for writing memos
Summary: Adds a new UI for writing access rule memos. Migrates old memos (written as Python comments) to the new UI. Test Plan: Browser and migration tests. Reviewers: jarek, dsagal Reviewed By: jarek Subscribers: dsagal, paulfitz Differential Revision: https://phab.getgrist.com/D3726
This commit is contained in:
35
app/client/aclui/ACLMemoEditor.ts
Normal file
35
app/client/aclui/ACLMemoEditor.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {dom, DomElementArg, Observable, styled} from 'grainjs';
|
||||
|
||||
export function aclMemoEditor(obs: Observable<string>, ...args: DomElementArg[]): HTMLInputElement {
|
||||
return cssMemoInput(
|
||||
dom.prop('value', obs),
|
||||
dom.on('input', (_e, elem) => obs.set(elem.value)),
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
const cssMemoInput = styled('input', `
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
padding: 4px 5px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
color: ${theme.accentText};
|
||||
background-color: ${theme.inputBg};
|
||||
caret-color : ${theme.inputFg};
|
||||
font: 12px 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
}
|
||||
&:not(&-disabled):focus-within {
|
||||
outline: none !important;
|
||||
cursor: text;
|
||||
box-shadow: inset 0 0 0 1px ${theme.accentBorder};
|
||||
border-color: ${theme.accentBorder};
|
||||
}
|
||||
`);
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import {aclColumnList} from 'app/client/aclui/ACLColumnList';
|
||||
import {aclFormulaEditor} from 'app/client/aclui/ACLFormulaEditor';
|
||||
import {aclMemoEditor} from 'app/client/aclui/ACLMemoEditor';
|
||||
import {aclSelect} from 'app/client/aclui/ACLSelect';
|
||||
import {ACLUsersPopup} from 'app/client/aclui/ACLUsers';
|
||||
import {PermissionKey, permissionsWidget} from 'app/client/aclui/PermissionsWidget';
|
||||
@@ -264,6 +265,7 @@ export class AccessRules extends Disposable {
|
||||
aclFormula: rule.aclFormula!,
|
||||
permissionsText: rule.permissionsText!,
|
||||
rulePos: rule.rulePos || null,
|
||||
memo: rule.memo ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -397,6 +399,7 @@ export class AccessRules extends Disposable {
|
||||
cssCellIcon(),
|
||||
cssCell2(cssColHeaderCell(t('Condition'))),
|
||||
cssCell1(cssColHeaderCell(t('Permissions'))),
|
||||
cssCellIconWithMargins(),
|
||||
cssCellIcon(),
|
||||
)
|
||||
)
|
||||
@@ -641,6 +644,7 @@ class TableRules extends Disposable {
|
||||
cssCellIcon(),
|
||||
cssCell2(cssColHeaderCell(t('Condition'))),
|
||||
cssCell1(cssColHeaderCell(t('Permissions'))),
|
||||
cssCellIconWithMargins(),
|
||||
cssCellIcon(),
|
||||
)
|
||||
),
|
||||
@@ -1050,6 +1054,7 @@ class SpecialObsRuleSet extends ColumnObsRuleSet {
|
||||
cssCellIcon(),
|
||||
cssCell4(cssColHeaderCell(getSpecialRuleName(this.getColIds()))),
|
||||
cssCell1(cssColHeaderCell('Permissions')),
|
||||
cssCellIconWithMargins(),
|
||||
cssCellIcon(),
|
||||
),
|
||||
cssTableRow(
|
||||
@@ -1272,6 +1277,16 @@ class ObsRulePart extends Disposable {
|
||||
private _permissions = Observable.create<PartialPermissionSet>(
|
||||
this, this._rulePart?.permissions || emptyPermissionSet());
|
||||
|
||||
// The memo text. Updated whenever changes are made within `_memoEditor`.
|
||||
private _memo: Observable<string>;
|
||||
|
||||
// Reference to the memo editor element, for triggering focus. Shown when
|
||||
// `_showMemoEditor` is true.
|
||||
private _memoEditor: HTMLInputElement | undefined;
|
||||
|
||||
// Is the memo editor visible? Initialized to true if a saved memo exists for this rule.
|
||||
private _showMemoEditor: Observable<boolean>;
|
||||
|
||||
// Whether the rule is being checked after a change. Saving will wait for such checks to finish.
|
||||
private _checkPending = Observable.create(this, false);
|
||||
|
||||
@@ -1283,13 +1298,20 @@ class ObsRulePart extends Disposable {
|
||||
// Error message if any validation failed.
|
||||
private _error: Computed<string>;
|
||||
|
||||
// rulePart is omitted for a new ObsRulePart added by the user. If given, isNew may be set to
|
||||
// treat the rule as new and only use the rulePart for its initialization.
|
||||
constructor(private _ruleSet: ObsRuleSet, private _rulePart?: RulePart, isNew = false) {
|
||||
super();
|
||||
this._memo = Observable.create(this, _rulePart?.memo ?? '');
|
||||
|
||||
// If this rule has a blank memo, don't show the editor.
|
||||
this._showMemoEditor = Observable.create(this, !this.isBuiltIn() && this._memo.get() !== '');
|
||||
|
||||
|
||||
if (_rulePart && isNew) {
|
||||
// rulePart is omitted for a new ObsRulePart added by the user. If given, isNew may be set to
|
||||
// treat the rule as new and only use the rulePart for its initialization.
|
||||
this._rulePart = undefined;
|
||||
}
|
||||
|
||||
this._error = Computed.create(this, (use) => {
|
||||
return use(this._formulaError) ||
|
||||
this._warnInvalidColIds(use(this._formulaProperties).usedColIds) ||
|
||||
@@ -1305,6 +1327,7 @@ class ObsRulePart extends Disposable {
|
||||
if (use(this._checkPending)) { return RuleStatus.CheckPending; }
|
||||
return getChangedStatus(
|
||||
use(this._aclFormula) !== this._rulePart?.aclFormula ||
|
||||
use(this._memo) !== (this._rulePart?.memo ?? '') ||
|
||||
!isEqual(use(this._permissions), this._rulePart?.permissions)
|
||||
);
|
||||
});
|
||||
@@ -1318,6 +1341,7 @@ class ObsRulePart extends Disposable {
|
||||
aclFormula: this._aclFormula.get(),
|
||||
permissionsText: permissionSetToText(this._permissions.get()),
|
||||
rulePos: this._rulePart?.origRecord?.rulePos as number|undefined,
|
||||
memo: this._memo.get(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1347,50 +1371,98 @@ class ObsRulePart extends Disposable {
|
||||
}
|
||||
|
||||
public buildRulePartDom(wide: boolean = false) {
|
||||
return cssColumnGroup(
|
||||
cssCellIcon(
|
||||
(this._isNonFirstBuiltIn() ?
|
||||
null :
|
||||
cssIconButton(icon('Plus'),
|
||||
dom.on('click', () => this._ruleSet.addRulePart(this)),
|
||||
testId('rule-add'),
|
||||
)
|
||||
return cssRulePartAndMemo(
|
||||
cssColumnGroup(
|
||||
cssCellIcon(
|
||||
(this._isNonFirstBuiltIn() ?
|
||||
null :
|
||||
cssIconButton(icon('Plus'),
|
||||
dom.on('click', () => this._ruleSet.addRulePart(this)),
|
||||
testId('rule-add'),
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
cssCell2(
|
||||
wide ? cssCell4.cls('') : null,
|
||||
aclFormulaEditor({
|
||||
initialValue: this._aclFormula.get(),
|
||||
readOnly: this.isBuiltIn(),
|
||||
setValue: (value) => this._setAclFormula(value),
|
||||
placeholder: dom.text((use) => {
|
||||
return (
|
||||
this._ruleSet.isSoleCondition(use, this) ? t('Everyone') :
|
||||
this._ruleSet.isLastCondition(use, this) ? t('EveryoneElse') :
|
||||
t('EnterCondition')
|
||||
);
|
||||
cssCell2(
|
||||
wide ? cssCell4.cls('') : null,
|
||||
aclFormulaEditor({
|
||||
initialValue: this._aclFormula.get(),
|
||||
readOnly: this.isBuiltIn(),
|
||||
setValue: (value) => this._setAclFormula(value),
|
||||
placeholder: dom.text((use) => {
|
||||
return (
|
||||
this._ruleSet.isSoleCondition(use, this) ? t('Everyone') :
|
||||
this._ruleSet.isLastCondition(use, this) ? t('EveryoneElse') :
|
||||
t('EnterCondition')
|
||||
);
|
||||
}),
|
||||
getSuggestions: (prefix) => this._completions.get(),
|
||||
}),
|
||||
getSuggestions: (prefix) => this._completions.get(),
|
||||
}),
|
||||
testId('rule-acl-formula'),
|
||||
testId('rule-acl-formula'),
|
||||
),
|
||||
cssCell1(cssCell.cls('-stretch'),
|
||||
permissionsWidget(this._ruleSet.getAvailableBits(), this._permissions,
|
||||
{disabled: this.isBuiltIn(), sanityCheck: (pset) => this.sanityCheck(pset)},
|
||||
testId('rule-permissions')
|
||||
),
|
||||
),
|
||||
cssCellIconWithMargins(
|
||||
dom.maybe(use => !this.isBuiltIn() && !use(this._showMemoEditor), () =>
|
||||
cssIconButton(icon('Memo'),
|
||||
dom.on('click', () => {
|
||||
this._showMemoEditor.set(true);
|
||||
// Note that focus is set when the memo icon is clicked, and not when
|
||||
// the editor is attached to the DOM; because rules with non-blank
|
||||
// memos have their editors visible by default when the page is first
|
||||
// loaded, focusing on creation could cause unintended focusing.
|
||||
setTimeout(() => this._memoEditor?.focus(), 0);
|
||||
}),
|
||||
testId('rule-memo-add'),
|
||||
)
|
||||
),
|
||||
),
|
||||
cssCellIcon(
|
||||
(this.isBuiltIn() ?
|
||||
null :
|
||||
cssIconButton(icon('Remove'),
|
||||
dom.on('click', () => this._ruleSet.removeRulePart(this)),
|
||||
testId('rule-remove'),
|
||||
)
|
||||
),
|
||||
),
|
||||
dom.maybe(this._error, (msg) => cssConditionError(msg, testId('rule-error'))),
|
||||
testId('rule-part'),
|
||||
),
|
||||
cssCell1(cssCell.cls('-stretch'),
|
||||
permissionsWidget(this._ruleSet.getAvailableBits(), this._permissions,
|
||||
{disabled: this.isBuiltIn(), sanityCheck: (pset) => this.sanityCheck(pset)},
|
||||
testId('rule-permissions')
|
||||
dom.maybe(this._showMemoEditor, () =>
|
||||
cssMemoColumnGroup(
|
||||
cssCellIcon(),
|
||||
cssMemoIcon('Memo'),
|
||||
cssCell2(
|
||||
wide ? cssCell4.cls('') : null,
|
||||
this._memoEditor = aclMemoEditor(this._memo,
|
||||
{
|
||||
placeholder: t('MemoEditorPlaceholder'),
|
||||
},
|
||||
dom.onKeyDown({
|
||||
// Match the behavior of the formula editor.
|
||||
Enter: (_ev, el) => el.blur(),
|
||||
}),
|
||||
),
|
||||
testId('rule-memo-editor'),
|
||||
),
|
||||
cssCellIconWithMargins(),
|
||||
cssCellIcon(
|
||||
cssIconButton(icon('Remove'),
|
||||
dom.on('click', () => {
|
||||
this._showMemoEditor.set(false);
|
||||
this._memo.set('');
|
||||
}),
|
||||
testId('rule-memo-remove'),
|
||||
),
|
||||
),
|
||||
testId('rule-memo'),
|
||||
),
|
||||
),
|
||||
cssCellIcon(
|
||||
(this.isBuiltIn() ?
|
||||
null :
|
||||
cssIconButton(icon('Remove'),
|
||||
dom.on('click', () => this._ruleSet.removeRulePart(this)),
|
||||
testId('rule-remove'),
|
||||
)
|
||||
),
|
||||
),
|
||||
dom.maybe(this._error, (msg) => cssConditionError(msg, testId('rule-error'))),
|
||||
testId('rule-part'),
|
||||
testId('rule-part-and-memo'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1652,6 +1724,7 @@ const cssCell = styled('div', `
|
||||
|
||||
// Variations on columns of different widths.
|
||||
const cssCellIcon = styled(cssCell, `flex: none; width: 24px;`);
|
||||
const cssCellIconWithMargins = styled(cssCellIcon, `margin: 0px 8px;`);
|
||||
const cssCell1 = styled(cssCell, `flex: 1;`);
|
||||
const cssCell2 = styled(cssCell, `flex: 2;`);
|
||||
const cssCell4 = styled(cssCell, `flex: 4;`);
|
||||
@@ -1704,3 +1777,19 @@ const cssRuleProblems = styled('div', `
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
`);
|
||||
|
||||
const cssRulePartAndMemo = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
`);
|
||||
|
||||
const cssMemoColumnGroup = styled(cssColumnGroup, `
|
||||
margin-bottom: 8px;
|
||||
`);
|
||||
|
||||
const cssMemoIcon = styled(icon, `
|
||||
--icon-color: ${theme.accentIcon};
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user