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/D2757pull/3/head
parent
e2d3b70509
commit
b4c34cedad
@ -1,169 +0,0 @@
|
|||||||
var ko = require('knockout');
|
|
||||||
var dispose = require('../lib/dispose');
|
|
||||||
var dom = require('../lib/dom');
|
|
||||||
var kd = require('../lib/koDom');
|
|
||||||
var kf = require('../lib/koForm');
|
|
||||||
var modelUtil = require('../models/modelUtil');
|
|
||||||
var gutil = require('app/common/gutil');
|
|
||||||
var AceEditor = require('./AceEditor');
|
|
||||||
var RefSelect = require('./RefSelect');
|
|
||||||
|
|
||||||
const {dom: grainjsDom, makeTestId} = require('grainjs');
|
|
||||||
const testId = makeTestId('test-fconfigtab-');
|
|
||||||
|
|
||||||
function FieldConfigTab(options) {
|
|
||||||
this.gristDoc = options.gristDoc;
|
|
||||||
this.fieldBuilder = options.fieldBuilder;
|
|
||||||
|
|
||||||
this.origColRef = this.autoDispose(ko.computed(() =>
|
|
||||||
this.fieldBuilder() ? this.fieldBuilder().origColumn.origColRef() : null));
|
|
||||||
|
|
||||||
this.isColumnValid = this.autoDispose(ko.computed(() => Boolean(this.origColRef())));
|
|
||||||
|
|
||||||
this.origColumn = this.autoDispose(
|
|
||||||
this.gristDoc.docModel.columns.createFloatingRowModel(this.origColRef));
|
|
||||||
|
|
||||||
this.disableModify = this.autoDispose(ko.computed(() =>
|
|
||||||
this.origColumn.disableModify() || this.origColumn.isTransforming()));
|
|
||||||
|
|
||||||
this.colId = modelUtil.customComputed({
|
|
||||||
read: () => this.origColumn.colId(),
|
|
||||||
save: val => this.origColumn.colId.saveOnly(val)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.showColId = this.autoDispose(ko.pureComputed({
|
|
||||||
read: () => {
|
|
||||||
let label = this.origColumn.label();
|
|
||||||
let derivedColId = label ? gutil.sanitizeIdent(label) : null;
|
|
||||||
return derivedColId === this.colId() && !this.origColumn.untieColIdFromLabel();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.isDerivedFromLabel = this.autoDispose(ko.pureComputed({
|
|
||||||
read: () => !this.origColumn.untieColIdFromLabel(),
|
|
||||||
write: newValue => this.origColumn.untieColIdFromLabel.saveOnly(!newValue)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Indicates whether this is a ref col that references a different table.
|
|
||||||
this.isForeignRefCol = this.autoDispose(ko.pureComputed(() => {
|
|
||||||
let type = this.origColumn.type();
|
|
||||||
return type && gutil.startsWith(type, 'Ref:') &&
|
|
||||||
this.origColumn.table().tableId() !== gutil.removePrefix(type, 'Ref:');
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Create an instance of AceEditor that can be built for each column
|
|
||||||
this.formulaEditor = this.autoDispose(AceEditor.create({observable: this.origColumn.formula}));
|
|
||||||
|
|
||||||
// Builder for the reference display column multiselect.
|
|
||||||
this.refSelect = this.autoDispose(RefSelect.create(this));
|
|
||||||
|
|
||||||
if (options.contentCallback) {
|
|
||||||
options.contentCallback(this.buildConfigDomObj());
|
|
||||||
} else {
|
|
||||||
this.autoDispose(this.gristDoc.addOptionsTab(
|
|
||||||
'Field', dom('span.glyphicon.glyphicon-sort-by-attributes'),
|
|
||||||
this.buildConfigDomObj(),
|
|
||||||
{ 'category': 'options', 'show': this.fieldBuilder }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dispose.makeDisposable(FieldConfigTab);
|
|
||||||
|
|
||||||
// Builds object with FieldConfigTab dom builder and settings for the sidepane.
|
|
||||||
// TODO: Field still cannot be filtered/filter settings cannot be opened from FieldConfigTab.
|
|
||||||
// This should be considered.
|
|
||||||
FieldConfigTab.prototype.buildConfigDomObj = function() {
|
|
||||||
return [{
|
|
||||||
'buildDom': this._buildNameDom.bind(this),
|
|
||||||
'keywords': ['field', 'column', 'name', 'title']
|
|
||||||
}, {
|
|
||||||
'header': true,
|
|
||||||
'items': [{
|
|
||||||
'buildDom': this._buildFormulaDom.bind(this),
|
|
||||||
'keywords': ['field', 'column', 'formula']
|
|
||||||
}]
|
|
||||||
}, {
|
|
||||||
'header': true,
|
|
||||||
'label': 'Format Cells',
|
|
||||||
'items': [{
|
|
||||||
'buildDom': this._buildFormatDom.bind(this),
|
|
||||||
'keywords': ['field', 'type', 'widget', 'options', 'alignment', 'justify', 'justification']
|
|
||||||
}]
|
|
||||||
}, {
|
|
||||||
'header': true,
|
|
||||||
'label': 'Additional Columns',
|
|
||||||
'showObs': this.isForeignRefCol,
|
|
||||||
'items': [{
|
|
||||||
'buildDom': () => this.refSelect.buildDom(),
|
|
||||||
'keywords': ['additional', 'columns', 'reference', 'formula']
|
|
||||||
}]
|
|
||||||
}, {
|
|
||||||
'header': true,
|
|
||||||
'label': 'Transform',
|
|
||||||
'items': [{
|
|
||||||
'buildDom': this._buildTransformDom.bind(this),
|
|
||||||
'keywords': ['field', 'type']
|
|
||||||
}]
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
|
|
||||||
FieldConfigTab.prototype._buildNameDom = function() {
|
|
||||||
return grainjsDom.maybe(this.isColumnValid, () => dom('div',
|
|
||||||
kf.row(
|
|
||||||
1, dom('div.glyphicon.glyphicon-sort-by-attributes.config_icon'),
|
|
||||||
4, kf.label('Field'),
|
|
||||||
13, kf.text(this.origColumn.label, { disabled: this.disableModify },
|
|
||||||
dom.testId("FieldConfigTab_fieldLabel"),
|
|
||||||
testId('field-label'))
|
|
||||||
),
|
|
||||||
kf.row(
|
|
||||||
kd.hide(this.showColId),
|
|
||||||
1, dom('div.glyphicon.glyphicon-tag.config_icon'),
|
|
||||||
4, kf.label('ID'),
|
|
||||||
13, kf.text(this.colId, { disabled: this.disableModify },
|
|
||||||
dom.testId("FieldConfigTab_colId"),
|
|
||||||
testId('field-col-id'))
|
|
||||||
),
|
|
||||||
kf.row(
|
|
||||||
8, kf.lightLabel("Use Name as ID?"),
|
|
||||||
1, kf.checkbox(this.isDerivedFromLabel,
|
|
||||||
dom.testId("FieldConfigTab_deriveId"),
|
|
||||||
testId('field-derive-id'))
|
|
||||||
)
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
FieldConfigTab.prototype._buildFormulaDom = function() {
|
|
||||||
return grainjsDom.maybe(this.isColumnValid, () => dom('div',
|
|
||||||
kf.row(
|
|
||||||
3, kf.buttonGroup(
|
|
||||||
kf.checkButton(this.origColumn.isFormula,
|
|
||||||
dom('span.formula_button_f', '\u0192'),
|
|
||||||
dom('span.formula_button_x', 'x'),
|
|
||||||
kd.toggleClass('disabled', this.disableModify),
|
|
||||||
{ title: 'Change to formula column' }
|
|
||||||
)
|
|
||||||
),
|
|
||||||
15, dom('div.transform_editor', this.formulaEditor.buildDom())
|
|
||||||
),
|
|
||||||
kf.helpRow(
|
|
||||||
3, dom('span'),
|
|
||||||
15, kf.lightLabel(kd.text(
|
|
||||||
() => this.origColumn.isFormula() ? 'Formula' : 'Default Formula'))
|
|
||||||
)
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
FieldConfigTab.prototype._buildTransformDom = function() {
|
|
||||||
return grainjsDom.maybe(this.fieldBuilder, builder => builder.buildTransformDom());
|
|
||||||
};
|
|
||||||
|
|
||||||
FieldConfigTab.prototype._buildFormatDom = function() {
|
|
||||||
return grainjsDom.maybe(this.fieldBuilder, builder => [
|
|
||||||
builder.buildSelectTypeDom(),
|
|
||||||
builder.buildSelectWidgetDom(),
|
|
||||||
builder.buildConfigDom()
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = FieldConfigTab;
|
|
@ -1,6 +1,6 @@
|
|||||||
// This module is unused except to group some modules for a webpack bundle.
|
// This module is unused except to group some modules for a webpack bundle.
|
||||||
// TODO It is a vestige of the old ViewPane.js, and can go away with some bundling improvements.
|
// TODO It is a vestige of the old ViewPane.js, and can go away with some bundling improvements.
|
||||||
|
|
||||||
import * as FieldConfigTab from 'app/client/components/FieldConfigTab';
|
|
||||||
import * as ViewConfigTab from 'app/client/components/ViewConfigTab';
|
import * as ViewConfigTab from 'app/client/components/ViewConfigTab';
|
||||||
export {FieldConfigTab, ViewConfigTab};
|
import * as FieldConfig from 'app/client/ui/FieldConfig';
|
||||||
|
export {ViewConfigTab, FieldConfig};
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
`);
|
@ -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;
|
||||||
|
`);
|
Loading…
Reference in new issue