From 995bf9b63aeb443e56bb297006fdce3179cf8e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Wed, 27 Apr 2022 17:53:47 +0200 Subject: [PATCH] (core) Distinct style rules for summary columns Summary: Summary columns now have their own conditional rules, which are not shared with sister columns. Test Plan: New test Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3388 --- app/common/schema.ts | 2 +- app/server/lib/initialDocSql.ts | 4 ++-- sandbox/grist/migrations.py | 36 +++++++++++++++++++++++++++++ sandbox/grist/schema.py | 2 +- sandbox/grist/summary.py | 33 +++++++++++++++++++++++++++ sandbox/grist/test_rules.py | 39 ++++++++++++++++++++++++++++++++ sandbox/grist/useractions.py | 12 ++++++---- test/fixtures/docs/Hello.grist | Bin 59392 -> 59392 bytes test/nbrowser/gristUtils.ts | 5 ++++ 9 files changed, 124 insertions(+), 9 deletions(-) diff --git a/app/common/schema.ts b/app/common/schema.ts index 44d71091..698dad7c 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData"; // tslint:disable:object-literal-key-quotes -export const SCHEMA_VERSION = 28; +export const SCHEMA_VERSION = 29; export const schema = { diff --git a/app/server/lib/initialDocSql.ts b/app/server/lib/initialDocSql.ts index 58968285..add6f0a5 100644 --- a/app/server/lib/initialDocSql.ts +++ b/app/server/lib/initialDocSql.ts @@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',28,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',29,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); @@ -42,7 +42,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',28,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',29,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index 10e369b4..045f278d 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -924,3 +924,39 @@ def migration28(tdset): doc_actions.append(actions.ModifyColumn(table.tableId, col.colId, {"type": "Attachments"})) return tdset.apply_doc_actions(doc_actions) + + +@migration(schema_version=29) +def migration29(tdset): + # This migration is fixing an error on summary tables with conditional rules. + # On summary tables all formula columns with the same name were updated together, + # which caused a situation where some summary columns have rules from diffrent tables. + # This migration is removing those rules in such columns. + + tables = {table.id: table + for table in actions.transpose_bulk_action(tdset.all_tables["_grist_Tables"])} + columns = {col.id: col + for col in actions.transpose_bulk_action(tdset.all_tables["_grist_Tables_column"])} + doc_actions = [] + + def is_valid_rule(parentId, rule_id): + # Valid rule should be an existing column, + rule_col = columns.get(rule_id) + # in the same table. + return rule_col and rule_col.parentId == parentId + + for col in columns.values(): + if col.rules: + # Parse rules (they are a json encoded array like '[15]') + rules = safe_parse(col.rules) + # Remove all conditional styles if anything about rules is invalid. + if not ( + isinstance(rules, list) and + all(is_valid_rule(col.parentId, ruleId) for ruleId in rules) + ): + doc_actions.append(actions.UpdateRecord('_grist_Tables_column', col.id, { + "rules": None, + "widgetOptions": summary._copy_widget_options(col.widgetOptions) + })) + + return tdset.apply_doc_actions(doc_actions) diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index 619bf85e..38bae5fa 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -15,7 +15,7 @@ import six import actions -SCHEMA_VERSION = 28 +SCHEMA_VERSION = 29 def make_column(col_id, col_type, formula='', isFormula=False): return { diff --git a/sandbox/grist/summary.py b/sandbox/grist/summary.py index 7650def6..6eea94b6 100644 --- a/sandbox/grist/summary.py +++ b/sandbox/grist/summary.py @@ -35,6 +35,39 @@ def _get_colinfo_dict(col_info, with_id=False): return col_values +def skip_rules_update(col, col_values): + """ + Rules for summary tables can't be derived from source columns. This function + removes (and kips original) rules settings when updating summary tables. + """ + + # Remove rules from updates. + col_values = {k: v for k, v in six.iteritems(col_values) if k != 'rules'} + + try: + # New widgetOptions to use. + new_widgetOptions = json.loads(col_values.get('widgetOptions', '')) + except ValueError: + # If we are not updating widgetOptions (or they are + # not a valid json string, i.e. in tests), just return the original updates. + return col_values + + try: + # Original widgetOptions (maybe with styling rules "ruleOptions"). + widgetOptions = json.loads(col.widgetOptions or '') + except ValueError: + widgetOptions = {} + + # Keep the original rulesOptions if any, and ignore any new one. + new_widgetOptions.pop("rulesOptions", "") + rulesOptions = widgetOptions.get('rulesOptions') + if rulesOptions: + new_widgetOptions['rulesOptions'] = rulesOptions + + col_values['widgetOptions'] = json.dumps(new_widgetOptions) + return col_values + + def _copy_widget_options(options): """Copies widgetOptions for a summary group-by column (omitting conditional formatting rules)""" if not options: diff --git a/sandbox/grist/test_rules.py b/sandbox/grist/test_rules.py index 65a7e078..b045904d 100644 --- a/sandbox/grist/test_rules.py +++ b/sandbox/grist/test_rules.py @@ -1,4 +1,8 @@ # -*- coding: utf-8 -*- +import json + +from collections import namedtuple +from summary import skip_rules_update import testutil import test_engine @@ -52,6 +56,41 @@ class TestRules(test_engine.EngineTestCase): rule = list(rules)[rule_index] return self.apply_user_action(['RemoveColumn', 'Inventory', rule.colId]) + def test_summary_updates(self): + Col = namedtuple('Col', 'widgetOptions') + col = Col(None) + # Should remove rules from update + self.assertEqual({}, skip_rules_update(col, {'rules': [15]})) + # Should leave col_updates untouched when there are no rules. + col_updates = {'type': 'Int'} + self.assertEqual(col_updates, skip_rules_update(col, col_updates)) + + # Should return same dict when not updating ruleOptions + col_updates = {'widgetOptions': '{"color": "red"}'} + self.assertEqual(col_updates, skip_rules_update(col, col_updates)) + col = Col('{"color": "red"}') + self.assertEqual(col_updates, skip_rules_update(col, col_updates)) + + # Should remove ruleOptions from update + col_updates = {'widgetOptions': '{"rulesOptions": [{"color": "black"}], "color": "blue"}'} + self.assertEqual({'widgetOptions': '{"color": "blue"}'}, + skip_rules_update(col, col_updates)) + col_updates = {'widgetOptions': '{"rulesOptions": [], "color": "blue"}'} + self.assertEqual({'widgetOptions': '{"color": "blue"}'}, + skip_rules_update(col, col_updates)) + + # Should preserve original ruleOptions + col = Col('{"rulesOptions": [{"color":"red"}], "color": "blue"}') + col_updates = {'widgetOptions': '{"rulesOptions": [{"color": "black"}], "color": "red"}'} + updated = skip_rules_update(col, col_updates) + self.assertEqual({"rulesOptions": [{"color": "red"}], "color": "red"}, + json.loads(updated.get('widgetOptions'))) + col_updates = {'widgetOptions': '{"color": "red"}'} + updated = skip_rules_update(col, col_updates) + self.assertEqual({"rulesOptions": [{"color": "red"}], "color": "red"}, + json.loads(updated.get('widgetOptions'))) + + def test_simple_rules(self): self.load_sample(self.sample) # Mark all records with Stock = 0 diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index 6696f473..2074ac85 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -748,10 +748,6 @@ class UserActions(object): # Returns a list of (col, values) pairs (containing the input column but possibly more). # Note that it may modify col_values in-place, and may reuse it for multiple results. - results = [] - def add(cols, value_dict): - results.extend((c, value_dict) for c in cols) - # If changing label, sync it to colId unless untieColIdFromLabel flag is set. if 'label' in col_values and not col_values.get('untieColIdFromLabel',col.untieColIdFromLabel): col_values.setdefault('colId', col_values['label']) @@ -780,13 +776,19 @@ class UserActions(object): col_values.setdefault('rules', None) col_values.setdefault('displayCol', 0) + # Collect all updates for dependent summary columns. + results = [] + def add(cols, value_dict): + results.extend((c, summary.skip_rules_update(c, value_dict)) for c in cols) + source_table = col.parentId.summarySourceTable if source_table: # This is a summary-table column. # Disallow isFormula changes. if has_diff_value(col_values, 'isFormula', col.isFormula): raise ValueError("Cannot change summary column '%s' between formula and data" % col.colId) - if col.isFormula: + # Don't update any sister helper columns. + if col.isFormula and not col.colId.startswith("gristHelper"): # Get all same-named formula columns from other summary tables for the same source table, # and apply the same changes to them. add(self._get_sister_columns(source_table, col), col_values) diff --git a/test/fixtures/docs/Hello.grist b/test/fixtures/docs/Hello.grist index 86cf9a46159ca72fd49fcef8e70c1b4ed525e343..501add7e1e118c530efeb21958d86e9c8a8b5ec8 100644 GIT binary patch delta 26 icmZp;z}#?wd4e<}&qNt#MxKocwc?Djo9~G$T>t=e=m@z0 delta 26 icmZp;z}#?wd4e<}_e2?IM(&LXwc?C2o9~G$T>t=e#R#?l diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index fb53342e..86b70b93 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1831,6 +1831,11 @@ export function hexToRgb(hex: string) { export async function addColumn(name: string) { await scrollIntoView(await driver.find('.active_section .mod-add-column')); await driver.find('.active_section .mod-add-column').click(); + // If we are on a summary table, we could be see a menu helper + const menu = (await driver.findAll('.grist-floating-menu'))[0]; + if (menu) { + await menu.findContent("li", name).click(); + } await waitForServer(); await waitAppFocus(false); await driver.sendKeys(name);