gristlabs_grist-core/app/client/aclui/PermissionsWidget.ts
2022-02-19 09:46:49 +00:00

180 lines
6.0 KiB
TypeScript

/**
* 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, sanityCheck?: (p: PartialPermissionSet) => void},
...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');
const setPermissions = (p: PartialPermissionSet) => {
options.sanityCheck?.(p);
pset.set(p);
};
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', () => setPermissions({...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(() => setPermissions(allowAll), tick(isEqual(pset.get(), allowAll)), 'Allow All',
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => setPermissions(denyAll), tick(isEqual(pset.get(), denyAll)), 'Deny All',
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => setPermissions(readOnly), tick(isEqual(pset.get(), readOnly)), 'Read Only',
dom.cls('disabled', options.disabled)
),
cssMenuItem(() => setPermissions(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 equivalent 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;
`);