mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Conditional formatting rules
Summary: Adding conditional formatting rules feature. Each column can have multiple styling rules which are applied in order when evaluated to a truthy value. - The creator panel has a new section: Cell Style - New user action AddEmptyRule for adding an empty rule - New columns in _grist_Table_columns and fields A new color picker will be introduced in a follow-up diff (as it is also used in choice/choice list/filters). Design document: https://grist.quip.com/FVzfAgoO5xOF/Conditional-Formatting-Implementation-Design Test Plan: new tests Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3282
This commit is contained in:
@@ -12,6 +12,7 @@ import six
|
||||
import records
|
||||
import usertypes
|
||||
import relabeling
|
||||
import lookup
|
||||
import table
|
||||
import moment
|
||||
from schema import RecalcWhen
|
||||
@@ -26,6 +27,14 @@ def _record_set(table_id, group_by, sort_by=None):
|
||||
return func
|
||||
|
||||
|
||||
def _record_ref_list_set(table_id, group_by, sort_by=None):
|
||||
@usertypes.formulaType(usertypes.ReferenceList(table_id))
|
||||
def func(rec, table):
|
||||
lookup_table = table.docmodel.get_table(table_id)
|
||||
return lookup_table.lookupRecords(sort_by=sort_by, **{group_by: lookup._Contains(rec.id)})
|
||||
return func
|
||||
|
||||
|
||||
def _record_inverse(table_id, ref_col):
|
||||
@usertypes.formulaType(usertypes.Reference(table_id))
|
||||
def func(rec, table):
|
||||
@@ -73,6 +82,8 @@ class MetaTableExtras(object):
|
||||
summaryGroupByColumns = _record_set('_grist_Tables_column', 'summarySourceCol')
|
||||
usedByCols = _record_set('_grist_Tables_column', 'displayCol')
|
||||
usedByFields = _record_set('_grist_Views_section_field', 'displayCol')
|
||||
ruleUsedByCols = _record_ref_list_set('_grist_Tables_column', 'rules')
|
||||
ruleUsedByFields = _record_ref_list_set('_grist_Views_section_field', 'rules')
|
||||
|
||||
def tableId(rec, table):
|
||||
return rec.parentId.tableId
|
||||
@@ -83,6 +94,12 @@ class MetaTableExtras(object):
|
||||
"""
|
||||
return len(rec.usedByCols) + len(rec.usedByFields)
|
||||
|
||||
def numRuleColUsers(rec, table):
|
||||
"""
|
||||
Returns the number of cols and fields using this col as a rule col
|
||||
"""
|
||||
return len(rec.ruleUsedByCols) + len(rec.ruleUsedByFields)
|
||||
|
||||
def recalcOnChangesToSelf(rec, table):
|
||||
"""
|
||||
Whether the column is a trigger-formula column that depends on itself, used for
|
||||
@@ -91,9 +108,10 @@ class MetaTableExtras(object):
|
||||
return rec.recalcWhen == RecalcWhen.DEFAULT and rec.id in rec.recalcDeps
|
||||
|
||||
def setAutoRemove(rec, table):
|
||||
"""Marks the col for removal if it's a display helper col with no more users."""
|
||||
table.docmodel.setAutoRemove(rec,
|
||||
rec.colId.startswith('gristHelper_Display') and rec.numDisplayColUsers == 0)
|
||||
"""Marks the col for removal if it's a display/rule helper col with no more users."""
|
||||
as_display = rec.colId.startswith('gristHelper_Display') and rec.numDisplayColUsers == 0
|
||||
as_rule = rec.colId.startswith('gristHelper_Conditional') and rec.numRuleColUsers == 0
|
||||
table.docmodel.setAutoRemove(rec, as_display or as_rule)
|
||||
|
||||
|
||||
class _grist_Views(object):
|
||||
|
||||
@@ -898,3 +898,11 @@ def migration26(tdset):
|
||||
new_view_section_id += 1
|
||||
|
||||
return tdset.apply_doc_actions(doc_actions)
|
||||
|
||||
|
||||
@migration(schema_version=27)
|
||||
def migration27(tdset):
|
||||
return tdset.apply_doc_actions([
|
||||
add_column('_grist_Tables_column', 'rules', 'RefList:_grist_Tables_column'),
|
||||
add_column('_grist_Views_section_field', 'rules', 'RefList:_grist_Tables_column'),
|
||||
])
|
||||
|
||||
@@ -15,7 +15,7 @@ import six
|
||||
|
||||
import actions
|
||||
|
||||
SCHEMA_VERSION = 26
|
||||
SCHEMA_VERSION = 27
|
||||
|
||||
def make_column(col_id, col_type, formula='', isFormula=False):
|
||||
return {
|
||||
@@ -83,6 +83,8 @@ def schema_create_actions():
|
||||
# E.g. Foo.person may have a visibleCol pointing to People.Name, with the displayCol
|
||||
# pointing to Foo._gristHelper_DisplayX column with the formula "$person.Name".
|
||||
make_column("visibleCol", "Ref:_grist_Tables_column"),
|
||||
# Points to formula columns that hold conditional formatting rules.
|
||||
make_column("rules", "RefList:_grist_Tables_column"),
|
||||
|
||||
# Instructions when to recalculate the formula on a column with isFormula=False (previously
|
||||
# known as a "default formula"). Values are RecalcWhen constants defined below.
|
||||
@@ -206,6 +208,8 @@ def schema_create_actions():
|
||||
make_column("visibleCol", "Ref:_grist_Tables_column"),
|
||||
# DEPRECATED: replaced with _grist_Filters in version 25. Do not remove or reuse.
|
||||
make_column("filter", "Text"),
|
||||
# Points to formula columns that hold conditional formatting rules for this field.
|
||||
make_column("rules", "RefList:_grist_Tables_column"),
|
||||
]),
|
||||
|
||||
# The code for all of the validation rules available to a Grist document
|
||||
|
||||
@@ -30,6 +30,17 @@ def _get_colinfo_dict(col_info, with_id=False):
|
||||
return col_values
|
||||
|
||||
|
||||
def _copy_widget_options(options):
|
||||
"""Copies widgetOptions for a summary group-by column (omitting conditional formatting rules)"""
|
||||
if not options:
|
||||
return options
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except ValueError:
|
||||
# widgetOptions are not always a valid json value (especially in tests)
|
||||
return options
|
||||
return json.dumps({k: v for k, v in options.items() if k != "rulesOptions"})
|
||||
|
||||
# To generate code, we need to know for each summary table, what its source table is. It would be
|
||||
# easy if we had access to metadata records, but (at least for now) we generate all code based on
|
||||
# schema only. So we encode the source table name inside of the summary table name.
|
||||
@@ -130,6 +141,7 @@ class SummaryActions(object):
|
||||
_get_colinfo_dict(ci, with_id=False))
|
||||
yield self.docmodel.columns.table.get_record(result['colRef'])
|
||||
|
||||
|
||||
def _get_or_create_summary(self, source_table, source_groupby_columns, formula_colinfo):
|
||||
"""
|
||||
Finds a summary table or creates a new one, based on source_table, grouped by the columns
|
||||
@@ -144,6 +156,7 @@ class SummaryActions(object):
|
||||
col=c,
|
||||
isFormula=False,
|
||||
formula='',
|
||||
widgetOptions=_copy_widget_options(c.widgetOptions),
|
||||
type=summary_groupby_col_type(c.type)
|
||||
)
|
||||
for c in source_groupby_columns
|
||||
|
||||
192
sandbox/grist/test_rules.py
Normal file
192
sandbox/grist/test_rules.py
Normal file
@@ -0,0 +1,192 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import testutil
|
||||
import test_engine
|
||||
|
||||
|
||||
class TestRules(test_engine.EngineTestCase):
|
||||
sample = testutil.parse_test_sample({
|
||||
"SCHEMA": [
|
||||
[1, "Inventory", [
|
||||
[2, "Label", "Text", False, "", "", ""],
|
||||
[3, "Stock", "Int", False, "", "", ""],
|
||||
]],
|
||||
],
|
||||
"DATA": {
|
||||
"Inventory": [
|
||||
["id", "Label", "Stock"],
|
||||
[1, "A1", 0],
|
||||
[2, "A2", 2],
|
||||
[3, "A3", 5],
|
||||
# Duplicate
|
||||
[4, "A1", 10]
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
# Helper for rules action
|
||||
def add_empty(self, col_id):
|
||||
return self.apply_user_action(['AddEmptyRule', "Inventory", 0, col_id])
|
||||
|
||||
def field_add_empty(self, field_id):
|
||||
return self.apply_user_action(['AddEmptyRule', "Inventory", field_id, 0])
|
||||
|
||||
def set_rule(self, col_id, rule_index, formula):
|
||||
rules = self.engine.docmodel.columns.table.get_record(col_id).rules
|
||||
rule = list(rules)[rule_index]
|
||||
return self.apply_user_action(['UpdateRecord', '_grist_Tables_column',
|
||||
rule.id, {"formula": formula}])
|
||||
|
||||
def field_set_rule(self, field_id, rule_index, formula):
|
||||
rules = self.engine.docmodel.view_fields.table.get_record(field_id).rules
|
||||
rule = list(rules)[rule_index]
|
||||
return self.apply_user_action(['UpdateRecord', '_grist_Tables_column',
|
||||
rule.id, {"formula": formula}])
|
||||
|
||||
def remove_rule(self, col_id, rule_index):
|
||||
rules = self.engine.docmodel.columns.table.get_record(col_id).rules
|
||||
rule = list(rules)[rule_index]
|
||||
return self.apply_user_action(['RemoveColumn', 'Inventory', rule.colId])
|
||||
|
||||
def field_remove_rule(self, field_id, rule_index):
|
||||
rules = self.engine.docmodel.view_fields.table.get_record(field_id).rules
|
||||
rule = list(rules)[rule_index]
|
||||
return self.apply_user_action(['RemoveColumn', 'Inventory', rule.colId])
|
||||
|
||||
def test_simple_rules(self):
|
||||
self.load_sample(self.sample)
|
||||
# Mark all records with Stock = 0
|
||||
out_actions = self.add_empty(3)
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["AddColumn", "Inventory", "gristHelper_ConditionalRule",
|
||||
{"formula": "", "isFormula": True, "type": "Any"}],
|
||||
["AddRecord", "_grist_Tables_column", 4,
|
||||
{"colId": "gristHelper_ConditionalRule", "formula": "", "isFormula": True,
|
||||
"label": "gristHelper_ConditionalRule", "parentId": 1, "parentPos": 3.0,
|
||||
"type": "Any",
|
||||
"widgetOptions": ""}],
|
||||
["UpdateRecord", "_grist_Tables_column", 3, {"rules": ["L", 4]}],
|
||||
]})
|
||||
out_actions = self.set_rule(3, 0, "$Stock == 0")
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["ModifyColumn", "Inventory", "gristHelper_ConditionalRule",
|
||||
{"formula": "$Stock == 0"}],
|
||||
["UpdateRecord", "_grist_Tables_column", 4, {"formula": "$Stock == 0"}],
|
||||
["BulkUpdateRecord", "Inventory", [1, 2, 3, 4],
|
||||
{"gristHelper_ConditionalRule": [True, False, False, False]}],
|
||||
]})
|
||||
|
||||
# Replace this rule with another rule to mark Stock = 2
|
||||
out_actions = self.set_rule(3, 0, "$Stock == 2")
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["ModifyColumn", "Inventory", "gristHelper_ConditionalRule",
|
||||
{"formula": "$Stock == 2"}],
|
||||
["UpdateRecord", "_grist_Tables_column", 4, {"formula": "$Stock == 2"}],
|
||||
["BulkUpdateRecord", "Inventory", [1, 2],
|
||||
{"gristHelper_ConditionalRule": [False, True]}],
|
||||
]})
|
||||
|
||||
# Add another rule Stock = 10
|
||||
out_actions = self.add_empty(3)
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["AddColumn", "Inventory", "gristHelper_ConditionalRule2",
|
||||
{"formula": "", "isFormula": True, "type": "Any"}],
|
||||
["AddRecord", "_grist_Tables_column", 5,
|
||||
{"colId": "gristHelper_ConditionalRule2", "formula": "", "isFormula": True,
|
||||
"label": "gristHelper_ConditionalRule2", "parentId": 1, "parentPos": 4.0,
|
||||
"type": "Any",
|
||||
"widgetOptions": ""}],
|
||||
["UpdateRecord", "_grist_Tables_column", 3, {"rules": ["L", 4, 5]}],
|
||||
]})
|
||||
out_actions = self.set_rule(3, 1, "$Stock == 10")
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["ModifyColumn", "Inventory", "gristHelper_ConditionalRule2",
|
||||
{"formula": "$Stock == 10"}],
|
||||
["UpdateRecord", "_grist_Tables_column", 5, {"formula": "$Stock == 10"}],
|
||||
["BulkUpdateRecord", "Inventory", [1, 2, 3, 4],
|
||||
{"gristHelper_ConditionalRule2": [False, False, False, True]}],
|
||||
]})
|
||||
|
||||
# Remove the last rule
|
||||
out_actions = self.remove_rule(3, 1)
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["RemoveRecord", "_grist_Tables_column", 5],
|
||||
["UpdateRecord", "_grist_Tables_column", 3, {"rules": ["L", 4]}],
|
||||
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule2"]
|
||||
]})
|
||||
|
||||
# Remove last rule
|
||||
out_actions = self.remove_rule(3, 0)
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["RemoveRecord", "_grist_Tables_column", 4],
|
||||
["UpdateRecord", "_grist_Tables_column", 3, {"rules": None}],
|
||||
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule"]
|
||||
]})
|
||||
|
||||
def test_duplicates(self):
|
||||
self.load_sample(self.sample)
|
||||
|
||||
# Create rule that marks duplicate values
|
||||
formula = "len(Inventory.lookupRecords(Label=$Label)) > 1"
|
||||
|
||||
# First add rule on stock column, to test naming - second rule column should have 2 as a suffix
|
||||
self.add_empty(3)
|
||||
self.set_rule(3, 0, "$Stock == 0")
|
||||
# Now highlight duplicates on labels
|
||||
self.add_empty(2)
|
||||
out_actions = self.set_rule(2, 0, formula)
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["ModifyColumn", "Inventory", "gristHelper_ConditionalRule2",
|
||||
{"formula": "len(Inventory.lookupRecords(Label=$Label)) > 1"}],
|
||||
["UpdateRecord", "_grist_Tables_column", 5,
|
||||
{"formula": "len(Inventory.lookupRecords(Label=$Label)) > 1"}],
|
||||
["BulkUpdateRecord", "Inventory", [1, 2, 3, 4],
|
||||
{"gristHelper_ConditionalRule2": [True, False, False, True]}]
|
||||
]})
|
||||
|
||||
def test_column_removal(self):
|
||||
# Test that rules are removed with a column.
|
||||
|
||||
self.load_sample(self.sample)
|
||||
self.add_empty(3)
|
||||
self.set_rule(3, 0, "$Stock == 0")
|
||||
before = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule')
|
||||
self.assertNotEqual(before, 0)
|
||||
out_actions = self.apply_user_action(['RemoveColumn', 'Inventory', 'Stock'])
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["BulkRemoveRecord", "_grist_Tables_column", [3, 4]],
|
||||
["RemoveColumn", "Inventory", "Stock"],
|
||||
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule"],
|
||||
]})
|
||||
|
||||
def test_column_removal_for_a_field(self):
|
||||
# Test that rules are removed with a column when attached to a field.
|
||||
|
||||
self.load_sample(self.sample)
|
||||
self.apply_user_action(['CreateViewSection', 1, 0, 'record', None])
|
||||
self.field_add_empty(2)
|
||||
self.field_set_rule(2, 0, "$Stock == 0")
|
||||
before = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule')
|
||||
self.assertNotEqual(before, 0)
|
||||
out_actions = self.apply_user_action(['RemoveColumn', 'Inventory', 'Stock'])
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["RemoveRecord", "_grist_Views_section_field", 2],
|
||||
["BulkRemoveRecord", "_grist_Tables_column", [3, 4]],
|
||||
["RemoveColumn", "Inventory", "Stock"],
|
||||
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule"],
|
||||
]})
|
||||
|
||||
def test_field_removal(self):
|
||||
# Test that rules are removed with a field.
|
||||
|
||||
self.load_sample(self.sample)
|
||||
self.apply_user_action(['CreateViewSection', 1, 0, 'record', None])
|
||||
self.field_add_empty(2)
|
||||
self.field_set_rule(2, 0, "$Stock == 0")
|
||||
rule_id = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule').id
|
||||
self.assertNotEqual(rule_id, 0)
|
||||
out_actions = self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 2])
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["RemoveRecord", "_grist_Views_section_field", 2],
|
||||
["RemoveRecord", "_grist_Tables_column", rule_id],
|
||||
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule"]
|
||||
]})
|
||||
@@ -183,7 +183,8 @@ def allowed_summary_change(key, updated, original):
|
||||
"""
|
||||
Checks if summary group by column can be modified.
|
||||
"""
|
||||
if updated == original:
|
||||
# Conditional styles are allowed
|
||||
if updated == original or key == 'rules':
|
||||
return True
|
||||
elif key == 'widgetOptions':
|
||||
try:
|
||||
@@ -196,7 +197,8 @@ def allowed_summary_change(key, updated, original):
|
||||
# TODO: move choice items to separate column
|
||||
allowed_to_change = {'widget', 'dateFormat', 'timeFormat', 'isCustomDateFormat', 'alignment',
|
||||
'fillColor', 'textColor', 'isCustomTimeFormat', 'isCustomDateFormat',
|
||||
'numMode', 'numSign', 'decimals', 'maxDecimals', 'currency'}
|
||||
'numMode', 'numSign', 'decimals', 'maxDecimals', 'currency',
|
||||
'rulesOptions'}
|
||||
# Helper function to remove protected keys from dictionary.
|
||||
def trim(options):
|
||||
return {k: v for k, v in options.items() if k not in allowed_to_change}
|
||||
@@ -1040,21 +1042,33 @@ class UserActions(object):
|
||||
re_sort_specs.append(json.dumps(updated_sort))
|
||||
self._docmodel.update(re_sort_sections, sortColRefs=re_sort_specs)
|
||||
|
||||
more_removals = set()
|
||||
# Remove all rules columns genereted for view fields for all removed columns.
|
||||
# Those columns would be auto-removed but we will remove them immediately to
|
||||
# avoid any recalculations.
|
||||
more_removals.update([rule for col in col_recs
|
||||
for field in col.viewFields
|
||||
for rule in field.rules])
|
||||
|
||||
# Remove all view fields for all removed columns.
|
||||
# Bypass the check for raw data view sections.
|
||||
field_ids = [f.id for c in col_recs for f in c.viewFields]
|
||||
|
||||
self.doBulkRemoveRecord("_grist_Views_section_field", field_ids)
|
||||
|
||||
# If there is a displayCol, it may get auto-removed, but may first produce calc actions
|
||||
# triggered by the removal of this column. To avoid those, remove displayCols immediately.
|
||||
# Also remove displayCol for any columns or fields that use this col as their visibleCol.
|
||||
more_removals = set()
|
||||
more_removals.update([c.displayCol for c in col_recs],
|
||||
[vc.displayCol for c in col_recs
|
||||
for vc in self._docmodel.columns.lookupRecords(visibleCol=c.id)],
|
||||
[vf.displayCol for c in col_recs
|
||||
for vf in self._docmodel.view_fields.lookupRecords(visibleCol=c.id)])
|
||||
|
||||
# Remove also all autogenereted formula columns for conditional styles.
|
||||
more_removals.update([rule for col in col_recs
|
||||
for rule in col.rules])
|
||||
|
||||
# Add any extra removals after removing the requested columns in the requested order.
|
||||
orig_removals = set(col_recs)
|
||||
all_removals = col_recs + sorted(c for c in more_removals if c.id and c not in orig_removals)
|
||||
@@ -1498,6 +1512,32 @@ class UserActions(object):
|
||||
if row_ids:
|
||||
self.BulkUpdateRecord('_grist_Filters', row_ids, {"filter": values})
|
||||
|
||||
|
||||
@useraction
|
||||
def AddEmptyRule(self, table_id, field_ref, col_ref):
|
||||
"""
|
||||
Adds empty conditional style rule to a field or column.
|
||||
"""
|
||||
assert table_id, "table_id is required"
|
||||
assert field_ref or col_ref, "field_ref or col_ref is required"
|
||||
assert not field_ref or not col_ref, "can't set both field_ref and col_ref"
|
||||
|
||||
if field_ref:
|
||||
field_or_col = self._docmodel.view_fields.table.get_record(field_ref)
|
||||
else:
|
||||
field_or_col = self._docmodel.columns.table.get_record(col_ref)
|
||||
|
||||
col_info = self.AddHiddenColumn(table_id, 'gristHelper_ConditionalRule', {
|
||||
"type": "Any",
|
||||
"isFormula": True,
|
||||
"formula": ''
|
||||
})
|
||||
new_rule = col_info['colRef']
|
||||
existing_rules = field_or_col.rules._get_encodable_row_ids() if field_or_col.rules else []
|
||||
updated_rules = existing_rules + [new_rule]
|
||||
self._docmodel.update([field_or_col], rules=[encode_object(updated_rules)])
|
||||
|
||||
|
||||
#----------------------------------------
|
||||
# User actions on tables.
|
||||
#----------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user