mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Update UI for formula and column label/id in the right-side panel.
Summary: - Update styling of label, id, and "derived ID from label" checkbox. - Implement a label which shows 'Data Column' vs 'Formula Column' vs 'Empty Column', and a dropdown with column actions (such as Clear/Convert) - Implement new formula display in the side-panel, and open the standard FormulaEditor when clicked. - Remove old FieldConfigTab, of which now very little would be used. - Fix up remaining code that relied on it (RefSelect) Test Plan: Fixed old tests, added new browser cases, and a case for a new helper function. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2757
This commit is contained in:
73
app/client/ui/CodeHighlight.ts
Normal file
73
app/client/ui/CodeHighlight.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import * as ace from 'brace';
|
||||
import {BindableValue, dom, DomElementArg, styled, subscribeElem} from 'grainjs';
|
||||
|
||||
// tslint:disable:no-var-requires
|
||||
require('brace/ext/static_highlight');
|
||||
require("brace/mode/python");
|
||||
require("brace/theme/chrome");
|
||||
|
||||
export interface ICodeOptions {
|
||||
placeholder?: string;
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
export function buildHighlightedCode(
|
||||
code: BindableValue<string>, options: ICodeOptions, ...args: DomElementArg[]
|
||||
): HTMLElement {
|
||||
const highlighter = ace.acequire('ace/ext/static_highlight');
|
||||
const PythonMode = ace.acequire('ace/mode/python').Mode;
|
||||
const theme = ace.acequire('ace/theme/chrome');
|
||||
const mode = new PythonMode();
|
||||
|
||||
return cssHighlightedCode(
|
||||
dom('div',
|
||||
elem => subscribeElem(elem, code, (codeText) => {
|
||||
if (codeText) {
|
||||
if (options.maxLines) {
|
||||
// If requested, trim to maxLines, and add an ellipsis at the end.
|
||||
// (Long lines are also truncated with an ellpsis via text-overflow style.)
|
||||
const lines = codeText.split(/\n/);
|
||||
if (lines.length > options.maxLines) {
|
||||
codeText = lines.slice(0, options.maxLines).join("\n") + " \u2026"; // Ellipsis
|
||||
}
|
||||
}
|
||||
elem.innerHTML = highlighter.render(codeText, mode, theme, 1, true).html;
|
||||
} else {
|
||||
elem.textContent = options.placeholder || '';
|
||||
}
|
||||
}),
|
||||
),
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
// Use a monospace font, a subset of what ACE editor seems to use.
|
||||
export const cssCodeBlock = styled('div', `
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: ${vars.smallFontSize};
|
||||
background-color: ${colors.light};
|
||||
&[disabled], &.disabled {
|
||||
background-color: ${colors.mediumGreyOpaque};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHighlightedCode = styled(cssCodeBlock, `
|
||||
position: relative;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-radius: 3px;
|
||||
min-height: 28px;
|
||||
padding: 5px 6px;
|
||||
color: ${colors.slate};
|
||||
|
||||
&.disabled, &.disabled .ace-chrome {
|
||||
background-color: ${colors.mediumGreyOpaque};
|
||||
}
|
||||
& .ace_line {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`);
|
||||
197
app/client/ui/FieldConfig.ts
Normal file
197
app/client/ui/FieldConfig.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type {GristDoc} from 'app/client/components/GristDoc';
|
||||
import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
||||
import {menu, menuItem} from 'app/client/ui2018/menus';
|
||||
import {sanitizeIdent} from 'app/common/gutil';
|
||||
import {Computed, dom, fromKo, IDisposableOwner, Observable, styled} from 'grainjs';
|
||||
|
||||
export function buildNameConfig(owner: IDisposableOwner, origColumn: ColumnRec) {
|
||||
const untieColId = origColumn.untieColIdFromLabel;
|
||||
|
||||
const editedLabel = Observable.create(owner, '');
|
||||
const editableColId = Computed.create(owner, editedLabel, (use, edited) =>
|
||||
'$' + (edited ? sanitizeIdent(edited) : use(origColumn.colId)));
|
||||
const saveColId = (val: string) => origColumn.colId.saveOnly(val.startsWith('$') ? val.slice(1) : val);
|
||||
|
||||
return [
|
||||
cssLabel('COLUMN LABEL AND ID'),
|
||||
cssRow(
|
||||
cssColLabelBlock(
|
||||
textInput(fromKo(origColumn.label),
|
||||
async val => { await origColumn.label.saveOnly(val); editedLabel.set(''); },
|
||||
dom.on('input', (ev, elem) => { if (!untieColId.peek()) { editedLabel.set(elem.value); } }),
|
||||
dom.boolAttr('disabled', origColumn.disableModify),
|
||||
testId('field-label'),
|
||||
),
|
||||
textInput(editableColId,
|
||||
saveColId,
|
||||
dom.boolAttr('disabled', use => use(origColumn.disableModify) || !use(origColumn.untieColIdFromLabel)),
|
||||
cssCodeBlock.cls(''),
|
||||
{style: 'margin-top: 8px'},
|
||||
testId('field-col-id'),
|
||||
),
|
||||
),
|
||||
cssColTieBlock(
|
||||
cssColTieConnectors(),
|
||||
cssToggleButton(icon('FieldReference'),
|
||||
cssToggleButton.cls('-selected', (use) => !use(untieColId)),
|
||||
dom.on('click', () => untieColId.saveOnly(!untieColId.peek())),
|
||||
testId('field-derive-id')
|
||||
),
|
||||
)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
type BuildEditor = (cellElem: Element) => void;
|
||||
|
||||
export function buildFormulaConfig(
|
||||
owner: IDisposableOwner, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
|
||||
) {
|
||||
const clearColumn = () => gristDoc.clearColumns([origColumn.id.peek()]);
|
||||
const convertToData = () => gristDoc.convertFormulasToData([origColumn.id.peek()]);
|
||||
|
||||
return dom.maybe(use => {
|
||||
if (!use(origColumn.id)) { return null; } // Invalid column, show nothing.
|
||||
if (use(origColumn.isEmpty)) { return "empty"; }
|
||||
return use(origColumn.isFormula) ? "formula" : "data";
|
||||
},
|
||||
(type: "empty"|"formula"|"data") => {
|
||||
function buildHeader(label: string, menuFunc: () => Element[]) {
|
||||
return cssRow(
|
||||
cssInlineLabel(label,
|
||||
testId('field-is-formula-label'),
|
||||
),
|
||||
cssDropdownLabel('Actions', icon('Dropdown'), menu(menuFunc),
|
||||
cssDropdownLabel.cls('-disabled', origColumn.disableModify),
|
||||
testId('field-actions-menu'),
|
||||
)
|
||||
);
|
||||
}
|
||||
function buildFormulaRow(placeholder = 'Enter formula') {
|
||||
return cssRow(dom.create(buildFormula, origColumn, buildEditor, placeholder));
|
||||
}
|
||||
if (type === "empty") {
|
||||
return [
|
||||
buildHeader('EMPTY COLUMN', () => [
|
||||
menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)),
|
||||
menuItem(convertToData, 'Make into data column'),
|
||||
]),
|
||||
buildFormulaRow(),
|
||||
];
|
||||
} else if (type === "formula") {
|
||||
return [
|
||||
buildHeader('FORMULA COLUMN', () => [
|
||||
menuItem(clearColumn, 'Clear column'),
|
||||
menuItem(convertToData, 'Convert to data column'),
|
||||
]),
|
||||
buildFormulaRow(),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
buildHeader('DATA COLUMN', () => [
|
||||
menuItem(clearColumn, 'Clear and make into formula'),
|
||||
]),
|
||||
buildFormulaRow('Default formula'),
|
||||
cssHintRow('Default formula for new records'),
|
||||
];
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildFormula(owner: IDisposableOwner, column: ColumnRec, buildEditor: BuildEditor, placeholder: string) {
|
||||
return cssFieldFormula(column.formula, {placeholder, maxLines: 2},
|
||||
dom.cls('formula_field_sidepane'),
|
||||
cssFieldFormula.cls('-disabled', column.disableModify),
|
||||
cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)),
|
||||
dom.cls('disabled'),
|
||||
{tabIndex: '-1'},
|
||||
dom.on('focus', (ev, elem) => buildEditor(elem)),
|
||||
);
|
||||
}
|
||||
|
||||
const cssFieldFormula = styled(buildHighlightedCode, `
|
||||
flex: auto;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
padding-left: 24px;
|
||||
--icon-color: ${colors.lightGreen};
|
||||
|
||||
&-disabled-icon.formula_field_sidepane::before {
|
||||
--icon-color: ${colors.slate};
|
||||
}
|
||||
&-disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssToggleButton = styled(cssIconButton, `
|
||||
margin-left: 8px;
|
||||
background-color: var(--grist-color-medium-grey-opaque);
|
||||
box-shadow: inset 0 0 0 1px ${colors.darkGrey};
|
||||
|
||||
&-selected, &-selected:hover {
|
||||
box-shadow: none;
|
||||
background-color: ${colors.dark};
|
||||
--icon-color: ${colors.light};
|
||||
}
|
||||
&-selected:hover {
|
||||
--icon-color: ${colors.darkGrey};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssInlineLabel = styled(cssLabel, `
|
||||
padding: 4px 8px;
|
||||
margin: 4px 0 -4px -8px;
|
||||
`);
|
||||
|
||||
const cssDropdownLabel = styled(cssInlineLabel, `
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: ${vars.controlBorderRadius};
|
||||
cursor: pointer;
|
||||
|
||||
color: ${colors.lightGreen};
|
||||
--icon-color: ${colors.lightGreen};
|
||||
|
||||
&:hover, &:focus, &.weasel-popup-open {
|
||||
background-color: ${colors.mediumGrey};
|
||||
}
|
||||
&-disabled {
|
||||
color: ${colors.slate};
|
||||
--icon-color: ${colors.slate};
|
||||
pointer-events: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHintRow = styled('div', `
|
||||
margin: -4px 16px 8px 16px;
|
||||
color: ${colors.slate};
|
||||
text-align: center;
|
||||
`);
|
||||
|
||||
const cssColLabelBlock = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`);
|
||||
|
||||
const cssColTieBlock = styled('div', `
|
||||
position: relative;
|
||||
`);
|
||||
|
||||
const cssColTieConnectors = styled('div', `
|
||||
position: absolute;
|
||||
border: 2px solid var(--grist-color-dark-grey);
|
||||
top: -9px;
|
||||
bottom: -9px;
|
||||
right: 11px;
|
||||
left: 0px;
|
||||
border-left: none;
|
||||
z-index: -1;
|
||||
`);
|
||||
@@ -15,9 +15,10 @@
|
||||
*/
|
||||
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import * as FieldConfigTab from 'app/client/components/FieldConfigTab';
|
||||
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
|
||||
import * as RefSelect from 'app/client/components/RefSelect';
|
||||
import * as ViewConfigTab from 'app/client/components/ViewConfigTab';
|
||||
import {domAsync} from 'app/client/lib/domAsync';
|
||||
import * as imports from 'app/client/lib/imports';
|
||||
import {createSessionObs} from 'app/client/lib/sessionObs';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
@@ -33,8 +34,9 @@ import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {select} from 'app/client/ui2018/menus';
|
||||
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
|
||||
import {StringUnion} from 'app/common/StringUnion';
|
||||
import {bundleChanges, Computed, Disposable, dom, DomArg, domComputed, DomContents,
|
||||
import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents,
|
||||
DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
|
||||
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
@@ -180,40 +182,53 @@ export class RightPanel extends Disposable {
|
||||
}
|
||||
|
||||
private _buildFieldContent(owner: MultiHolder) {
|
||||
const obs: Observable<null|FieldConfigTab> = Observable.create(owner, null);
|
||||
const fieldBuilder = this.autoDispose(ko.computed(() => {
|
||||
const fieldBuilder = owner.autoDispose(ko.computed(() => {
|
||||
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
|
||||
return vsi && vsi.activeFieldBuilder();
|
||||
}));
|
||||
const gristDoc = this._gristDoc;
|
||||
const content = Observable.create<TabContent[]>(owner, []);
|
||||
const contentCallback = (tabs: TabContent[]) => content.set(tabs);
|
||||
imports.loadViewPane()
|
||||
.then(ViewPane => {
|
||||
if (owner.isDisposed()) { return; }
|
||||
const fct = owner.autoDispose(ViewPane.FieldConfigTab.create({gristDoc, fieldBuilder, contentCallback}));
|
||||
obs.set(fct);
|
||||
})
|
||||
.catch(reportError);
|
||||
return dom.maybe(obs, (fct) =>
|
||||
buildConfigContainer(
|
||||
cssLabel('COLUMN TITLE'),
|
||||
fct._buildNameDom(),
|
||||
fct._buildFormulaDom(),
|
||||
cssSeparator(),
|
||||
cssLabel('COLUMN TYPE'),
|
||||
fct._buildFormatDom(),
|
||||
cssSeparator(),
|
||||
dom.maybe(fct.isForeignRefCol, () => [
|
||||
cssLabel('Add referenced columns'),
|
||||
cssRow(fct.refSelect.buildDom()),
|
||||
cssSeparator()
|
||||
]),
|
||||
cssLabel('TRANSFORM'),
|
||||
fct._buildTransformDom(),
|
||||
this._disableIfReadonly(),
|
||||
)
|
||||
);
|
||||
|
||||
const docModel = this._gristDoc.docModel;
|
||||
const origColRef = owner.autoDispose(ko.computed(() => fieldBuilder()?.origColumn.origColRef() || 0));
|
||||
const origColumn = owner.autoDispose(docModel.columns.createFloatingRowModel(origColRef));
|
||||
const isColumnValid = owner.autoDispose(ko.computed(() => Boolean(origColRef())));
|
||||
|
||||
// Builder for the reference display column multiselect.
|
||||
const refSelect = owner.autoDispose(RefSelect.create({docModel, origColumn, fieldBuilder}));
|
||||
|
||||
return domAsync(imports.loadViewPane().then(ViewPane => {
|
||||
const {buildNameConfig, buildFormulaConfig} = ViewPane.FieldConfig;
|
||||
return dom.maybe(isColumnValid, () =>
|
||||
buildConfigContainer(
|
||||
dom.create(buildNameConfig, origColumn),
|
||||
cssSeparator(),
|
||||
dom.create(buildFormulaConfig, origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)),
|
||||
cssSeparator(),
|
||||
cssLabel('COLUMN TYPE'),
|
||||
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
|
||||
builder.buildSelectTypeDom(),
|
||||
builder.buildSelectWidgetDom(),
|
||||
builder.buildConfigDom()
|
||||
]),
|
||||
cssSeparator(),
|
||||
dom.maybe(refSelect.isForeignRefCol, () => [
|
||||
cssLabel('Add referenced columns'),
|
||||
cssRow(refSelect.buildDom()),
|
||||
cssSeparator()
|
||||
]),
|
||||
cssLabel('TRANSFORM'),
|
||||
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => builder.buildTransformDom()),
|
||||
this._disableIfReadonly(),
|
||||
)
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
// Helper to activate the side-pane formula editor over the given HTML element.
|
||||
private _activateFormulaEditor(refElem: Element) {
|
||||
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
|
||||
if (!vsi) { return; }
|
||||
const editRowModel = vsi.moveEditRowToCursor();
|
||||
vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem);
|
||||
}
|
||||
|
||||
private _buildPageWidgetContent(_owner: MultiHolder) {
|
||||
@@ -452,7 +467,7 @@ export class RightPanel extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildConfigContainer(...args: DomElementArg[]): DomArg {
|
||||
export function buildConfigContainer(...args: DomElementArg[]): HTMLElement {
|
||||
return cssConfigContainer(
|
||||
// The `position: relative;` style is needed for the overlay for the readonly mode. Note that
|
||||
// we cannot set it on the cssConfigContainer directly because it conflicts with how overflow
|
||||
@@ -631,7 +646,7 @@ const cssTabContents = styled('div', `
|
||||
|
||||
const cssSeparator = styled('div', `
|
||||
border-bottom: 1px solid ${colors.mediumGrey};
|
||||
margin-top: 24px;
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
const cssConfigContainer = styled('div', `
|
||||
@@ -681,6 +696,6 @@ const cssListItem = styled('li', `
|
||||
padding: 4px 8px;
|
||||
`);
|
||||
|
||||
const cssTextInput = styled(textInput, `
|
||||
export const cssTextInput = styled(textInput, `
|
||||
flex: 1 0 auto;
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user