From cefea55198c4229445e2fed3660bbcc4dc361526 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Aug 2023 10:02:03 -0400 Subject: [PATCH 01/14] automated update to translation keys (#613) Co-authored-by: Paul's Grist Bot --- static/locales/en.client.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 99924f77..fd928b5f 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -853,7 +853,8 @@ "Mixed style": "Mixed style", "Open row styles": "Open row styles", "Default header style": "Default header style", - "Header Style": "Header Style" + "Header Style": "Header Style", + "HEADER STYLE": "HEADER STYLE" }, "ChoiceTextBox": { "CHOICES": "CHOICES" From 884803592c3cdb1c54790894b2759da111d1730a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Aug 2023 14:26:56 -0400 Subject: [PATCH 02/14] automated update to translation keys (#617) Co-authored-by: Paul's Grist Bot --- static/locales/en.client.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/locales/en.client.json b/static/locales/en.client.json index fd928b5f..893cbda5 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -1077,7 +1077,8 @@ "Sign Up for Free": "Sign Up for Free", "Sign up for a free Grist account to start using the Formula AI Assistant.": "Sign up for a free Grist account to start using the Formula AI Assistant.", "There are some things you should know when working with me:": "There are some things you should know when working with me:", - "What do you need help with?": "What do you need help with?" + "What do you need help with?": "What do you need help with?", + "Formula AI Assistant is only available for logged in users.": "Formula AI Assistant is only available for logged in users." }, "GridView": { "Click to insert": "Click to insert" From 3c444895828077949a9a0b40bbc60807c723bf4b Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Wed, 9 Aug 2023 18:50:59 +0000 Subject: [PATCH 03/14] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (949 of 949 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt_BR/ --- static/locales/pt_BR.client.json | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index d6ba906f..59f8cd8d 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -483,7 +483,18 @@ "{{count}} unmatched field_other": "{{count}} campos sem equivalente", "{{count}} unmatched field in import_other": "{{count}} campos sem equivalente na importação", "{{count}} unmatched field in import_one": "{{count}} campo sem equivalente na importação", - "{{count}} unmatched field_one": "{{count}} campo sem equivalente" + "{{count}} unmatched field_one": "{{count}} campo sem equivalente", + "Column Mapping": "Mapeamento de coluna", + "Column mapping": "Mapeamento de coluna", + "Destination table": "Tabela de destino", + "Grist column": "Coluna de Grist", + "New Table": "Nova tabela", + "Revert": "Reverter", + "Skip": "Pular", + "Skip Import": "Pular importação", + "Source column": "Coluna de origem", + "Import from file": "Importar de arquivo", + "Skip Table on Import": "Pular tabela na importação" }, "LeftPanelCommon": { "Help Center": "Centro de Ajuda" @@ -996,7 +1007,9 @@ "Cell Style": "Estilo de célula", "Default cell style": "Estilo de célula padrão", "Mixed style": "Estilo misto", - "Open row styles": "Estilos de linha aberta" + "Open row styles": "Estilos de linha aberta", + "Default header style": "Estilo de cabeçalho padrão", + "Header Style": "Estilo de cabeçalho" }, "ColumnEditor": { "COLUMN DESCRIPTION": "DESCRIÇÃO DA COLUNA", From 652cc345fd85af9c508eca93abd9f735339f8157 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Wed, 9 Aug 2023 13:52:49 +0000 Subject: [PATCH 04/14] Translated using Weblate (Spanish) Currently translated at 100.0% (949 of 949 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index 466ba0ff..b19f1c31 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -786,7 +786,18 @@ "{{count}} unmatched field in import_one": "{{count}} campo no coincide con la importación", "{{count}} unmatched field_one": "{{count}} campo sin equivalente", "{{count}} unmatched field_other": "{{count}} campos sin equivalentes", - "{{count}} unmatched field in import_other": "{{count}} campos sin equivalente en la importación" + "{{count}} unmatched field in import_other": "{{count}} campos sin equivalente en la importación", + "Column Mapping": "Asignación de columnas", + "Column mapping": "Asignación de las columnas", + "Import from file": "Importar desde un archivo", + "New Table": "Tabla nueva", + "Revert": "Revertir", + "Skip": "Omitir", + "Skip Import": "Omitir la importación", + "Source column": "Columna de origen", + "Destination table": "Tabla de destino", + "Grist column": "Columna Grist", + "Skip Table on Import": "Omitir la tabla en la importación" }, "PermissionsWidget": { "Allow All": "Permitir todo", @@ -894,7 +905,9 @@ "Default cell style": "Estilo de celda predeterminado", "CELL STYLE": "ESTILO DE CELDA", "Open row styles": "Abrir estilos de fila", - "Mixed style": "Estilo mixto" + "Mixed style": "Estilo mixto", + "Header Style": "Estilo del encabezado", + "Default header style": "Estilo del encabezado por defecto" }, "ColumnInfo": { "Cancel": "Cancelar", From 3fcd093ea6ff5a17f4c15cd566fcfe0041eb7580 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Wed, 9 Aug 2023 18:56:40 +0000 Subject: [PATCH 05/14] Translated using Weblate (German) Currently translated at 100.0% (949 of 949 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/de/ --- static/locales/de.client.json | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/static/locales/de.client.json b/static/locales/de.client.json index 89dc570b..5ef25070 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -483,7 +483,18 @@ "{{count}} unmatched field in import_other": "{{count}} unangepasste Felder im Import", "{{count}} unmatched field_one": "{{count}} unangepasstes Feld", "{{count}} unmatched field_other": "{{count}} unangepasste Felder", - "{{count}} unmatched field in import_one": "{{count}} unangepasst Feld im Import" + "{{count}} unmatched field in import_one": "{{count}} unangepasst Feld im Import", + "Column mapping": "Spaltenzuordnung", + "Column Mapping": "Spaltenzuordnung", + "Destination table": "Zieltabelle", + "Grist column": "Grist Spalte", + "Import from file": "Aus Datei importieren", + "New Table": "Neue Tabelle", + "Revert": "Zurücksetzen", + "Skip": "überspringen", + "Skip Import": "Import überspringen", + "Skip Table on Import": "Tabelle beim Import überspringen", + "Source column": "Quellenspalte" }, "LeftPanelCommon": { "Help Center": "Hilfe-Center" @@ -904,7 +915,9 @@ "Open row styles": "Zeilenstile öffnen", "Cell Style": "Zellenstil", "Default cell style": "Standard-Zellenstil", - "Mixed style": "Gemischter Stil" + "Mixed style": "Gemischter Stil", + "Default header style": "Standard Kopfzeilenstil", + "Header Style": "Kopfzeilenstil" }, "DiscussionEditor": { "Resolve": "Beschließen", From 605a3022e945c16ab7f6717317180cca56408fe4 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 11 Aug 2023 07:50:01 +0000 Subject: [PATCH 06/14] Translated using Weblate (Spanish) Currently translated at 100.0% (951 of 951 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index b19f1c31..d896f9fd 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -907,7 +907,8 @@ "Open row styles": "Abrir estilos de fila", "Mixed style": "Estilo mixto", "Header Style": "Estilo del encabezado", - "Default header style": "Estilo del encabezado por defecto" + "Default header style": "Estilo del encabezado por defecto", + "HEADER STYLE": "ESTILO DEL ENCABEZAMIENTO" }, "ColumnInfo": { "Cancel": "Cancelar", @@ -1130,7 +1131,8 @@ "Hi, I'm the Grist Formula AI Assistant.": "Hola, soy el Asistente de IA de Fórmula Grist.", "I can only help with formulas. I cannot build tables, columns, and views, or write access rules.": "Sólo puedo ayudar con fórmulas. No puedo construir tablas, columnas y vistas, ni escribir reglas de acceso.", "Sign Up for Free": "Regístrate gratis", - "There are some things you should know when working with me:": "Hay algunas cosas que debes saber cuando trabajes conmigo:" + "There are some things you should know when working with me:": "Hay algunas cosas que debes saber cuando trabajes conmigo:", + "Formula AI Assistant is only available for logged in users.": "Formula AI Assistant sólo está disponible para usuarios registrados." }, "GridView": { "Click to insert": "Haga clic para insertar" @@ -1203,7 +1205,7 @@ "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "Una vez que haya eliminado su propio acceso, no podrá recuperarlo sin la ayuda de otra persona con suficiente acceso al {{resourceType}}.", "Add {{member}} to your team": "Añadir {{member}} a tu equipo", "Allow anyone with the link to open.": "Permita que cualquiera que tenga el enlace lo abra.", - "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Ningún acceso predeterminado permite el acceso a documentos individuales o espacios de trabajo, en lugar del sitio completo del equipo.", + "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "El acceso no predeterminado permite conceder acceso a documentos o espacios de trabajo individuales, en lugar de a toda la página del equipo.", "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{name}}.": "Una vez que haya eliminado su propio acceso, no podrá recuperarlo sin la ayuda de otra persona con suficiente acceso al {{name}}.", "Public access": "Acceso público", "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Acceso público heredado de {{parent}}. Para eliminar, establezca la opción 'Heredar acceso' en 'Ninguno'.", From e93ad5a0c5250a5af52e2db0217e6fcc4fbd421b Mon Sep 17 00:00:00 2001 From: Florent Date: Fri, 11 Aug 2023 15:12:43 +0200 Subject: [PATCH 07/14] Issue #601: implement PUT on columns (#605) * Also Add includeHidden param for GET on columns --- app/plugin/DocApiTypes-ti.ts | 5 ++ app/plugin/DocApiTypes.ts | 4 ++ app/server/lib/DocApi.ts | 62 ++++++++++++++++- test/server/lib/DocApi.ts | 130 ++++++++++++++++++++++++++++++++++- 4 files changed, 197 insertions(+), 4 deletions(-) diff --git a/app/plugin/DocApiTypes-ti.ts b/app/plugin/DocApiTypes-ti.ts index 0f6c26dc..366eb22f 100644 --- a/app/plugin/DocApiTypes-ti.ts +++ b/app/plugin/DocApiTypes-ti.ts @@ -68,6 +68,10 @@ export const ColumnsPatch = t.iface([], { "columns": t.tuple("RecordWithStringId", t.rest(t.array("RecordWithStringId"))), }); +export const ColumnsPut = t.iface([], { + "columns": t.tuple("RecordWithStringId", t.rest(t.array("RecordWithStringId"))), +}); + export const TablePost = t.iface(["ColumnsPost"], { "id": t.opt("string"), }); @@ -93,6 +97,7 @@ const exportedTypeSuite: t.ITypeSuite = { MinimalRecord, ColumnsPost, ColumnsPatch, + ColumnsPut, TablePost, TablesPost, TablesPatch, diff --git a/app/plugin/DocApiTypes.ts b/app/plugin/DocApiTypes.ts index 8f9fc049..998b635b 100644 --- a/app/plugin/DocApiTypes.ts +++ b/app/plugin/DocApiTypes.ts @@ -88,6 +88,10 @@ export interface ColumnsPatch { columns: [RecordWithStringId, ...RecordWithStringId[]]; // at least one column is required } +export interface ColumnsPut { + columns: [RecordWithStringId, ...RecordWithStringId[]]; // at least one column is required +} + /** * Creating tables requires a list of columns. * `fields` is not accepted because it's not generally sensible to set the metadata fields on new tables. diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index f49773e5..5e20b1fd 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -2,7 +2,14 @@ import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer import {createEmptyActionSummary} from "app/common/ActionSummary"; import {ApiError, LimitType} from 'app/common/ApiError'; import {BrowserSettings} from "app/common/BrowserSettings"; -import {BulkColValues, ColValues, fromTableDataAction, TableColValues, TableRecordValue} from 'app/common/DocActions'; +import { + BulkColValues, + ColValues, + fromTableDataAction, + TableColValues, + TableRecordValue, + UserAction +} from 'app/common/DocActions'; import {isRaisedException} from "app/common/gristTypes"; import {buildUrlId, parseUrlId} from "app/common/gristUrls"; import {isAffirmative} from "app/common/gutil"; @@ -94,7 +101,7 @@ type WithDocHandler = (activeDoc: ActiveDoc, req: RequestWithLogin, resp: Respon // Schema validators for api endpoints that creates or updates records. const { RecordsPatch, RecordsPost, RecordsPut, - ColumnsPost, ColumnsPatch, + ColumnsPost, ColumnsPatch, ColumnsPut, TablesPost, TablesPatch, } = t.createCheckers(DocApiTypesTI, GristDataTI); @@ -357,8 +364,9 @@ export class DocWorkerApi { this._app.get('/api/docs/:docId/tables/:tableId/columns', canView, withDoc(async (activeDoc, req, res) => { const tableId = req.params.tableId; + const includeHidden = isAffirmative(req.query.includeHidden); const columns = await handleSandboxError('', [], - activeDoc.getTableCols(docSessionFromRequest(req), tableId)); + activeDoc.getTableCols(docSessionFromRequest(req), tableId, includeHidden)); res.json({columns}); }) ); @@ -681,6 +689,54 @@ export class DocWorkerApi { }) ); + // Add or update records given in records format + this._app.put('/api/docs/:docId/tables/:tableId/columns', canEdit, validate(ColumnsPut), + withDoc(async (activeDoc, req, res) => { + const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables"); + const columnsTable = activeDoc.docData!.getMetaTable("_grist_Tables_column"); + const {tableId} = req.params; + const tableRef = tablesTable.findMatchingRowId({tableId}); + if (!tableRef) { + throw new ApiError(`Table not found "${tableId}"`, 404); + } + const body = req.body as Types.ColumnsPut; + + const addActions: UserAction[] = []; + const updateActions: UserAction[] = []; + const updatedColumnsIds = new Set(); + + for (const col of body.columns) { + const id = columnsTable.findMatchingRowId({parentId: tableRef, colId: col.id}); + if (id) { + updateActions.push( ['UpdateRecord', '_grist_Tables_column', id, col.fields] ); + updatedColumnsIds.add( id ); + } else { + addActions.push( ['AddVisibleColumn', tableId, col.id, col.fields] ); + } + } + + const getRemoveAction = async () => { + const columns = await handleSandboxError('', [], + activeDoc.getTableCols(docSessionFromRequest(req), tableId)); + const columnsToRemove = columns + .map(col => col.fields.colRef as number) + .filter(colRef => !updatedColumnsIds.has(colRef)); + + return [ 'BulkRemoveRecord', '_grist_Tables_column', columnsToRemove ]; + }; + + const actions = [ + ...(!isAffirmative(req.query.noupdate) ? updateActions : []), + ...(!isAffirmative(req.query.noadd) ? addActions : []), + ...(isAffirmative(req.query.replaceall) ? [ await getRemoveAction() ] : [] ) + ]; + await handleSandboxError(tableId, [], + activeDoc.applyUserActions(docSessionFromRequest(req), actions) + ); + res.json(null); + }) + ); + // Add a new webhook and trigger this._app.post('/api/docs/:docId/webhooks', isOwner, validate(WebhookSubscribeCollection), withDoc(async (activeDoc, req, res) => { diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 768113f2..90abecff 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -5,7 +5,7 @@ import {arrayRepeat} from 'app/common/gutil'; import {WebhookSummary} from 'app/common/Triggers'; import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI'; import {testDailyApiLimitFeatures} from 'app/gen-server/entity/Product'; -import {AddOrUpdateRecord, Record as ApiRecord} from 'app/plugin/DocApiTypes'; +import {AddOrUpdateRecord, Record as ApiRecord, ColumnsPut, RecordWithStringId} from 'app/plugin/DocApiTypes'; import {CellValue, GristObjCode} from 'app/plugin/GristData'; import { applyQueryParameters, @@ -610,6 +610,21 @@ function testDocApi() { ); }); + it("GET /docs/{did}/tables/{tid}/columns retrieves hidden columns when includeHidden is set", async function () { + const params = { includeHidden: true }; + const resp = await axios.get( + `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/columns`, + { ...chimpy, params } + ); + assert.equal(resp.status, 200); + const columnsMap = new Map(resp.data.columns.map(({id, fields}: {id: string, fields: object}) => [id, fields])); + assert.include([...columnsMap.keys()], "manualSort"); + assert.deepInclude(columnsMap.get("manualSort"), { + colRef: 1, + type: 'ManualSortPos', + }); + }); + it("GET/POST/PATCH /docs/{did}/tables and /columns", async function () { // POST /tables: Create new tables let resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables`, { @@ -842,6 +857,119 @@ function testDocApi() { assert.equal(resp.status, 200); }); + describe("PUT /docs/{did}/columns", function () { + + async function generateDocAndUrl() { + const wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id; + const docId = await userApi.newDoc({name: 'ColumnsPut'}, wid); + const url = `${serverUrl}/api/docs/${docId}/tables/Table1/columns`; + return { url, docId }; + } + + async function getColumnFieldsMapById(url: string) { + const result = await axios.get(url, chimpy); + assert.equal(result.status, 200); + return new Map( + result.data.columns.map( + ({id, fields}: {id: string, fields: object}) => [id, fields] + ) + ); + } + + async function checkPut( + columns: [RecordWithStringId, ...RecordWithStringId[]], + params: Record, + expectedFieldsByColId: Record, + ) { + const {url} = await generateDocAndUrl(); + const body: ColumnsPut = { columns }; + const resp = await axios.put(url, body, {...chimpy, params}); + assert.equal(resp.status, 200); + const fieldsByColId = await getColumnFieldsMapById(url); + + assert.deepEqual( + [...fieldsByColId.keys()], + Object.keys(expectedFieldsByColId), + "The updated table should have the expected columns" + ); + + for (const [colId, expectedFields] of Object.entries(expectedFieldsByColId)) { + assert.deepInclude(fieldsByColId.get(colId), expectedFields); + } + } + + const COLUMN_TO_ADD = { + id: "Foo", + fields: { + type: "Text", + label: "FooLabel", + } + }; + + const COLUMN_TO_UPDATE = { + id: "A", + fields: { + type: "Numeric", + colId: "NewA" + } + }; + + it('should create new columns', async function () { + await checkPut([COLUMN_TO_ADD], {}, { + A: {}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields + }); + }); + + it('should update existing columns and create new ones', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {}, { + NewA: {type: "Numeric", label: "A"}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields + }); + }); + + it('should only update existing columns when noadd is set', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {noadd: "1"}, { + NewA: {type: "Numeric"}, B: {}, C: {} + }); + }); + + it('should only add columns when noupdate is set', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {noupdate: "1"}, { + A: {type: "Any"}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields + }); + }); + + it('should remove existing columns if replaceall is set', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {replaceall: "1"}, { + NewA: {type: "Numeric"}, Foo: COLUMN_TO_ADD.fields + }); + }); + + it('should forbid update by viewers', async function () { + // given + const { url, docId } = await generateDocAndUrl(); + await userApi.updateDocPermissions(docId, {users: {'kiwi@getgrist.com': 'viewers'}}); + + // when + const resp = await axios.put(url, { columns: [ COLUMN_TO_ADD ] }, kiwi); + + // then + assert.equal(resp.status, 403); + }); + + it("should return 404 when table is not found", async function() { + // given + const { url } = await generateDocAndUrl(); + const notFoundUrl = url.replace("Table1", "NonExistingTable"); + + // when + const resp = await axios.put(notFoundUrl, { columns: [ COLUMN_TO_ADD ] }, chimpy); + + // then + assert.equal(resp.status, 404); + assert.equal(resp.data.error, 'Table not found "NonExistingTable"'); + }); + }); + it("GET /docs/{did}/tables/{tid}/data returns 404 for non-existent doc", async function () { const resp = await axios.get(`${serverUrl}/api/docs/typotypotypo/tables/Table1/data`, chimpy); assert.equal(resp.status, 404); From a3467c35c66143abb86c4abb0d54ffabbf9a7112 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Fri, 11 Aug 2023 16:48:49 +0000 Subject: [PATCH 08/14] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (951 of 951 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt_BR/ --- static/locales/pt_BR.client.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index 59f8cd8d..aee8d1f1 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -1009,7 +1009,8 @@ "Mixed style": "Estilo misto", "Open row styles": "Estilos de linha aberta", "Default header style": "Estilo de cabeçalho padrão", - "Header Style": "Estilo de cabeçalho" + "Header Style": "Estilo de cabeçalho", + "HEADER STYLE": "ESTILO DE CABEÇALHO" }, "ColumnEditor": { "COLUMN DESCRIPTION": "DESCRIÇÃO DA COLUNA", @@ -1140,7 +1141,8 @@ "Sign up for a free Grist account to start using the Formula AI Assistant.": "Cadastre-se para uma conta gratuita do Grist para começar a usar o Assistente de Fórmula AI.", "What do you need help with?": "Em que você precisa de ajuda?", "There are some things you should know when working with me:": "Há algumas coisas que você deve saber ao trabalhar comigo:", - "Learn more": "Saiba mais" + "Learn more": "Saiba mais", + "Formula AI Assistant is only available for logged in users.": "O Assistente de Fórmula de IA só está disponível para usuários registrados." }, "GridView": { "Click to insert": "Clique para inserir" From b9564e41516218c5b8d738e3d9e77519ec480114 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Fri, 11 Aug 2023 16:56:58 +0000 Subject: [PATCH 09/14] Translated using Weblate (Spanish) Currently translated at 100.0% (951 of 951 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index d896f9fd..8a0bebdc 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -908,7 +908,7 @@ "Mixed style": "Estilo mixto", "Header Style": "Estilo del encabezado", "Default header style": "Estilo del encabezado por defecto", - "HEADER STYLE": "ESTILO DEL ENCABEZAMIENTO" + "HEADER STYLE": "ESTILO DEL ENCABEZADO" }, "ColumnInfo": { "Cancel": "Cancelar", @@ -1205,7 +1205,7 @@ "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "Una vez que haya eliminado su propio acceso, no podrá recuperarlo sin la ayuda de otra persona con suficiente acceso al {{resourceType}}.", "Add {{member}} to your team": "Añadir {{member}} a tu equipo", "Allow anyone with the link to open.": "Permita que cualquiera que tenga el enlace lo abra.", - "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "El acceso no predeterminado permite conceder acceso a documentos o espacios de trabajo individuales, en lugar de a toda la página del equipo.", + "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Ningún acceso predeterminado permite el acceso a documentos individuales o espacios de trabajo, en lugar del sitio completo del equipo.", "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{name}}.": "Una vez que haya eliminado su propio acceso, no podrá recuperarlo sin la ayuda de otra persona con suficiente acceso al {{name}}.", "Public access": "Acceso público", "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Acceso público heredado de {{parent}}. Para eliminar, establezca la opción 'Heredar acceso' en 'Ninguno'.", From 244b6b91aa6f12072c4d01a5d46a3b267f8d3e8c Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 11 Aug 2023 07:51:10 +0000 Subject: [PATCH 10/14] Translated using Weblate (Spanish) Currently translated at 100.0% (951 of 951 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index 8a0bebdc..4d11ebde 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -776,7 +776,7 @@ "Unmark On-Demand": "Desmarcar bajo demanda" }, "FilterBar": { - "SearchColumns": "Buscar columnas", + "SearchColumns": "Buscar en columnas", "Search Columns": "Buscar columnas" }, "Importer": { From 166726bc50878267a8f59c982c7d1eaa2c11bd79 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Fri, 11 Aug 2023 16:51:56 +0000 Subject: [PATCH 11/14] Translated using Weblate (German) Currently translated at 100.0% (951 of 951 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/de/ --- static/locales/de.client.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/static/locales/de.client.json b/static/locales/de.client.json index 5ef25070..6e21356d 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -917,7 +917,8 @@ "Default cell style": "Standard-Zellenstil", "Mixed style": "Gemischter Stil", "Default header style": "Standard Kopfzeilenstil", - "Header Style": "Kopfzeilenstil" + "Header Style": "Kopfzeilenstil", + "HEADER STYLE": "KOPFSTIL" }, "DiscussionEditor": { "Resolve": "Beschließen", @@ -1140,7 +1141,8 @@ "Sign Up for Free": "Melden Sie sich kostenlos an", "Sign up for a free Grist account to start using the Formula AI Assistant.": "Melden Sie sich für ein kostenloses Grist-Konto an, um den KI Formel Assistenten zu verwenden.", "There are some things you should know when working with me:": "Es gibt einige Dinge, die Sie wissen sollten, wenn Sie mit mir arbeiten:", - "What do you need help with?": "Womit brauchen Sie Hilfe?" + "What do you need help with?": "Womit brauchen Sie Hilfe?", + "Formula AI Assistant is only available for logged in users.": "Der Formel-KI-Assistent ist nur für eingeloggte Benutzer verfügbar." }, "GridView": { "Click to insert": "Zum Einfügen klicken" From fde56ffa335065d85ed0386169a9a8229b101a0d Mon Sep 17 00:00:00 2001 From: Florent Date: Mon, 14 Aug 2023 16:17:46 +0200 Subject: [PATCH 12/14] DocApi: Introduce `hidden` option for GET on records (#623) Also test that PUT /columns?replaceall=1 does not remove hidden columns --- app/server/lib/DocApi.ts | 33 +++++++++++++++++++++------------ test/server/lib/DocApi.ts | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 5e20b1fd..33cc558c 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -201,16 +201,23 @@ export class DocWorkerApi { } async function getTableRecords( - activeDoc: ActiveDoc, req: RequestWithLogin, optTableId?: string + activeDoc: ActiveDoc, req: RequestWithLogin, opts?: { optTableId?: string; includeHidden?: boolean } ): Promise { - const columnData = await getTableData(activeDoc, req, optTableId); - const fieldNames = Object.keys(columnData) - .filter(k => !( - ["id", "manualSort"].includes(k) - || k.startsWith("gristHelper_") - )); + const columnData = await getTableData(activeDoc, req, opts?.optTableId); + const fieldNames = Object.keys(columnData).filter((k) => { + if (k === "id") { + return false; + } + if ( + !opts?.includeHidden && + (k === "manualSort" || k.startsWith("gristHelper_")) + ) { + return false; + } + return true; + }); return columnData.id.map((id, index) => { - const result: TableRecordValue = {id, fields: {}}; + const result: TableRecordValue = { id, fields: {} }; for (const key of fieldNames) { let value = columnData[key][index]; if (isRaisedException(value)) { @@ -233,7 +240,9 @@ export class DocWorkerApi { // Get the specified table in record-oriented format this._app.get('/api/docs/:docId/tables/:tableId/records', canView, withDoc(async (activeDoc, req, res) => { - const records = await getTableRecords(activeDoc, req); + const records = await getTableRecords(activeDoc, req, + { includeHidden: isAffirmative(req.query.hidden) } + ); res.json({records}); }) ); @@ -364,7 +373,7 @@ export class DocWorkerApi { this._app.get('/api/docs/:docId/tables/:tableId/columns', canView, withDoc(async (activeDoc, req, res) => { const tableId = req.params.tableId; - const includeHidden = isAffirmative(req.query.includeHidden); + const includeHidden = isAffirmative(req.query.hidden); const columns = await handleSandboxError('', [], activeDoc.getTableCols(docSessionFromRequest(req), tableId, includeHidden)); res.json({columns}); @@ -374,7 +383,7 @@ export class DocWorkerApi { // Get the tables of the specified document in recordish format this._app.get('/api/docs/:docId/tables', canView, withDoc(async (activeDoc, req, res) => { - const records = await getTableRecords(activeDoc, req, "_grist_Tables"); + const records = await getTableRecords(activeDoc, req, { optTableId: "_grist_Tables" }); const tables = records.map((record) => ({ id: record.fields.tableId, fields: { @@ -403,7 +412,7 @@ export class DocWorkerApi { // Returns cleaned metadata for all attachments in /records format. this._app.get('/api/docs/:docId/attachments', canView, withDoc(async (activeDoc, req, res) => { - const rawRecords = await getTableRecords(activeDoc, req, "_grist_Attachments"); + const rawRecords = await getTableRecords(activeDoc, req, { optTableId: "_grist_Attachments" }); const records = rawRecords.map(r => ({ id: r.id, fields: cleanAttachmentRecord(r.fields as MetaRowRecord<"_grist_Attachments">), diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 90abecff..35348f13 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -443,6 +443,26 @@ function testDocApi() { }); }); + it('GET /docs/{did}/tables/{tid}/records honors the "hidden" param', async function () { + const params = { hidden: true }; + const resp = await axios.get( + `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/records`, + {...chimpy, params } + ); + assert.equal(resp.status, 200); + assert.deepEqual(resp.data.records[0], { + id: 1, + fields: { + manualSort: 1, + A: 'hello', + B: '', + C: '', + D: null, + E: 'HELLO', + }, + }); + }); + it("GET /docs/{did}/tables/{tid}/records handles errors and hidden columns", async function () { let resp = await axios.get(`${serverUrl}/api/docs/${docIds.ApiDataRecordsTest}/tables/Table1/records`, chimpy); assert.equal(resp.status, 200); @@ -610,8 +630,8 @@ function testDocApi() { ); }); - it("GET /docs/{did}/tables/{tid}/columns retrieves hidden columns when includeHidden is set", async function () { - const params = { includeHidden: true }; + it('GET /docs/{did}/tables/{tid}/columns retrieves hidden columns when "hidden" is set', async function () { + const params = { hidden: true }; const resp = await axios.get( `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/columns`, { ...chimpy, params } @@ -866,8 +886,8 @@ function testDocApi() { return { url, docId }; } - async function getColumnFieldsMapById(url: string) { - const result = await axios.get(url, chimpy); + async function getColumnFieldsMapById(url: string, params: any) { + const result = await axios.get(url, {...chimpy, params}); assert.equal(result.status, 200); return new Map( result.data.columns.map( @@ -880,12 +900,13 @@ function testDocApi() { columns: [RecordWithStringId, ...RecordWithStringId[]], params: Record, expectedFieldsByColId: Record, + opts?: { getParams?: any } ) { const {url} = await generateDocAndUrl(); const body: ColumnsPut = { columns }; const resp = await axios.put(url, body, {...chimpy, params}); assert.equal(resp.status, 200); - const fieldsByColId = await getColumnFieldsMapById(url); + const fieldsByColId = await getColumnFieldsMapById(url, opts?.getParams); assert.deepEqual( [...fieldsByColId.keys()], @@ -944,6 +965,12 @@ function testDocApi() { }); }); + it('should NOT remove hidden columns even when replaceall is set', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {replaceall: "1"}, { + manualSort: {type: "ManualSortPos"}, NewA: {type: "Numeric"}, Foo: COLUMN_TO_ADD.fields + }, { getParams: { hidden: true } }); + }); + it('should forbid update by viewers', async function () { // given const { url, docId } = await generateDocAndUrl(); From f4d2c866d2c598f4988c56a84c16a86b201bc7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?= Date: Sun, 13 Aug 2023 15:39:37 +0000 Subject: [PATCH 13/14] Translated using Weblate (Russian) Currently translated at 99.5% (947 of 951 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/ --- static/locales/ru.client.json | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index c6de2f37..f9ac25b4 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -549,7 +549,18 @@ "{{count}} unmatched field in import_other": "{{count}} несовпадающие поля при импорте", "{{count}} unmatched field in import_one": "{{count}} несовпадающее поле при импорте", "{{count}} unmatched field_one": "{{count}} несовпадающее поле", - "{{count}} unmatched field_other": "{{count}} несовпадающие поля" + "{{count}} unmatched field_other": "{{count}} несовпадающие поля", + "Column Mapping": "Сопоставление столбцов", + "Column mapping": "Сопоставление столбцов", + "Destination table": "Таблица назначения", + "Grist column": "Grist столбец", + "Import from file": "Импорт из файла", + "New Table": "Новая таблица", + "Revert": "Восстановить", + "Skip": "Пропустить", + "Skip Table on Import": "Пропустить Таблицу при импорте", + "Source column": "Столбец Источника", + "Skip Import": "Пропустить импорт" }, "LeftPanelCommon": { "Help Center": "Справочный центр" @@ -757,7 +768,10 @@ "Default cell style": "Стиль ячейки по умолчанию", "Open row styles": "Открыть форматы строк", "Cell Style": "Стиль ячейки", - "Mixed style": "Смешанный стиль" + "Mixed style": "Смешанный стиль", + "Default header style": "Стиль заголовка по умолчанию", + "HEADER STYLE": "СТИЛЬ ЗАГОЛОВКА", + "Header Style": "Стиль заголовка" }, "TypeTransform": { "Cancel": "Отмена", @@ -1063,7 +1077,8 @@ "Sign up for a free Grist account to start using the Formula AI Assistant.": "Зарегистрируйте бесплатную учетную запись Grist, чтобы начать использовать AI Помощника по формулам.", "There are some things you should know when working with me:": "Есть некоторые вещи, которые вы должны знать, работая со мной:", "What do you need help with?": "Как я могу вам помочь?", - "Hi, I'm the Grist Formula AI Assistant.": "Привет, я AI Помощник по формулам Grist." + "Hi, I'm the Grist Formula AI Assistant.": "Привет, я AI Помощник по формулам Grist.", + "Formula AI Assistant is only available for logged in users.": "AI Ассистент для формул доступен только для зарегистрированных пользователей." }, "GridView": { "Click to insert": "Нажмите для вставки" From cbdffdfff88b64cfe56194b24b3bf3a9140f05b8 Mon Sep 17 00:00:00 2001 From: Philip Standt Date: Mon, 14 Aug 2023 18:28:41 +0200 Subject: [PATCH 14/14] Support grid selection with Ctrl+Shift+Arrow (#615) --- app/client/components/GridView.js | 140 +++++++++++++++- app/client/components/commandList.ts | 20 +++ test/fixtures/docs/ShiftSelection.grist | Bin 0 -> 286720 bytes test/nbrowser/ShiftSelection.ts | 213 ++++++++++++++++++++++++ 4 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/docs/ShiftSelection.grist create mode 100644 test/nbrowser/ShiftSelection.ts diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 1e567508..b0517bbf 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -316,6 +316,20 @@ GridView.gridCommands = { this._shiftSelect(-1, this.cellSelector.col.end, selector.ROW, this.viewSection.viewFields().peekLength - 1); }, + ctrlShiftDown: function () { + this._shiftSelectUntilContent(selector.COL, 1, this.cellSelector.row.end, this.getLastDataRowIndex()); + }, + ctrlShiftUp: function () { + this._shiftSelectUntilContent(selector.COL, -1, this.cellSelector.row.end, this.getLastDataRowIndex()); + }, + ctrlShiftRight: function () { + this._shiftSelectUntilContent(selector.ROW, 1, this.cellSelector.col.end, + this.viewSection.viewFields().peekLength - 1); + }, + ctrlShiftLeft: function () { + this._shiftSelectUntilContent(selector.ROW, -1, this.cellSelector.col.end, + this.viewSection.viewFields().peekLength - 1); + }, fillSelectionDown: function() { this.fillSelectionDown(); }, selectAll: function() { this.selectAll(); }, @@ -403,6 +417,120 @@ GridView.prototype._shiftSelect = function(step, selectObs, exemptType, maxVal) selectObs(newVal); }; +GridView.prototype._shiftSelectUntilContent = function(type, direction, selectObs, maxVal) { + const selection = { + colStart: this.cellSelector.col.start(), + colEnd: this.cellSelector.col.end(), + rowStart: this.cellSelector.row.start(), + rowEnd: this.cellSelector.row.end(), + }; + + const steps = this._stepsToContent(type, direction, selection, maxVal); + if (steps > 0) { this._shiftSelect(direction * steps, selectObs, type, maxVal); } +} + +GridView.prototype._stepsToContent = function (type, direction, selection, maxVal) { + const {colEnd: colEnd, rowEnd: rowEnd} = selection; + let selectionData; + + const cursorCol = this.cursor.fieldIndex(); + const cursorRow = this.cursor.rowIndex(); + + if (type === selector.ROW && direction > 0) { + if (colEnd + 1 > maxVal) { return 0; } + + selectionData = this._selectionData({colStart: colEnd, colEnd: maxVal, rowStart: cursorRow, rowEnd: cursorRow}); + } else if (type === selector.ROW && direction < 0) { + if (colEnd - 1 < 0) { return 0; } + + selectionData = this._selectionData({colStart: 0, colEnd, rowStart: cursorRow, rowEnd: cursorRow}); + } else if (type === selector.COL && direction > 0) { + if (rowEnd + 1 > maxVal) { return 0; } + + selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: rowEnd, rowEnd: maxVal}); + } else if (type === selector.COL && direction < 0) { + if (rowEnd - 1 > maxVal) { return 0; } + + selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: 0, rowEnd}); + } + + const {fields, rowIndices} = selectionData; + if (type === selector.ROW && direction < 0) { + // When moving selection left, we step through fields in reverse order. + fields.reverse(); + } + if (type === selector.COL && direction < 0) { + // When moving selection up, we step through rows in reverse order. + rowIndices.reverse(); + } + + const colValuesByIndex = {}; + for (const field of fields) { + const displayColId = field.displayColModel.peek().colId.peek(); + colValuesByIndex[field._index()] = this.tableModel.tableData.getColValues(displayColId); + } + + let steps = 0; + + if (type === selector.ROW) { + const rowIndex = rowIndices[0]; + const isLastColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd][rowIndex]); + const isNextColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd + direction][rowIndex]); + const shouldStopOnEmptyValue = !isLastColEmpty && !isNextColEmpty; + for (let i = 1; i < fields.length; i++) { + const hasEmptyValues = this._isCellValueEmpty(colValuesByIndex[fields[i]._index()][rowIndex]); + if (hasEmptyValues && shouldStopOnEmptyValue) { + return steps; + } else if (!hasEmptyValues && !shouldStopOnEmptyValue) { + return steps + 1; + } + + steps += 1; + } + } else { + const colValues = colValuesByIndex[fields[0]._index()]; + const isLastRowEmpty = this._isCellValueEmpty(colValues[rowIndices[0]]); + const isNextRowEmpty = this._isCellValueEmpty(colValues[rowIndices[1]]); + const shouldStopOnEmptyValue = !isLastRowEmpty && !isNextRowEmpty; + for (let i = 1; i < rowIndices.length; i++) { + const hasEmptyValues = this._isCellValueEmpty(colValues[rowIndices[i]]); + if (hasEmptyValues && shouldStopOnEmptyValue) { + return steps; + } else if (!hasEmptyValues && !shouldStopOnEmptyValue) { + return steps + 1; + } + + steps += 1; + } + } + + return steps; +} + +GridView.prototype._selectionData = function({colStart, colEnd, rowStart, rowEnd}) { + const fields = []; + for (let i = colStart; i <= colEnd; i++) { + const field = this.viewSection.viewFields().at(i); + if (!field) { continue; } + + fields.push(field); + } + + const rowIndices = []; + for (let i = rowStart; i <= rowEnd; i++) { + const rowId = this.viewData.getRowId(i); + if (!rowId) { continue; } + + rowIndices.push(this.tableModel.tableData.getRowIdIndex(rowId)); + } + + return {fields, rowIndices}; +} + +GridView.prototype._isCellValueEmpty = function(value) { + return value === null || value === undefined || value === '' || value === 'false'; +} + /** * Pastes the provided data at the current cursor. * @@ -532,15 +660,15 @@ GridView.prototype.fillSelectionDown = function() { /** - * Returns a GridSelection of the selected rows and cols + * Returns a CopySelection of the selected rows and cols * @returns {Object} CopySelection */ GridView.prototype.getSelection = function() { - var rowIds = [], fields = [], rowStyle = {}, colStyle = {}; - var colStart = this.cellSelector.colLower(); - var colEnd = this.cellSelector.colUpper(); - var rowStart = this.cellSelector.rowLower(); - var rowEnd = this.cellSelector.rowUpper(); + let rowIds = [], fields = [], rowStyle = {}, colStyle = {}; + let colStart = this.cellSelector.colLower(); + let colEnd = this.cellSelector.colUpper(); + let rowStart = this.cellSelector.rowLower(); + let rowEnd = this.cellSelector.rowUpper(); // If there is no selection, just copy/paste the cursor cell if (this.cellSelector.isCurrentSelectType(selector.NONE)) { diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts index 65f03399..54a972df 100644 --- a/app/client/components/commandList.ts +++ b/app/client/components/commandList.ts @@ -50,6 +50,10 @@ export type CommandName = | 'shiftUp' | 'shiftRight' | 'shiftLeft' + | 'ctrlShiftDown' + | 'ctrlShiftUp' + | 'ctrlShiftRight' + | 'ctrlShiftLeft' | 'selectAll' | 'copyLink' | 'editField' @@ -373,6 +377,22 @@ export const groups: CommendGroupDef[] = [{ name: 'shiftLeft', keys: ['Shift+Left'], desc: 'Adds the element to the left of the cursor to the selected range' + }, { + name: 'ctrlShiftDown', + keys: ['Mod+Shift+Down'], + desc: 'Adds all elements below the cursor to the selected range' + }, { + name: 'ctrlShiftUp', + keys: ['Mod+Shift+Up'], + desc: 'Adds all elements above the cursor to the selected range' + }, { + name: 'ctrlShiftRight', + keys: ['Mod+Shift+Right'], + desc: 'Adds all elements to the right of the cursor to the selected range' + }, { + name: 'ctrlShiftLeft', + keys: ['Mod+Shift+Left'], + desc: 'Adds all elements to the left of the cursor to the selected range' }, { name: 'selectAll', keys: ['Mod+A'], diff --git a/test/fixtures/docs/ShiftSelection.grist b/test/fixtures/docs/ShiftSelection.grist new file mode 100644 index 0000000000000000000000000000000000000000..083021d4d5ee338e5e942e8e04f46c787cf44b97 GIT binary patch literal 286720 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

!}`ieQg3&qjh7^2`=_^R-CW5*K`I)eFy!ETYh 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 gcUJ=3D_; { + let start, end; + + let selectionActive = false; + const elements = await parent.findAll(selector); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const isSelected = await element.matches('.selected'); + + if (isSelected && !selectionActive) { + start = i; + selectionActive = true; + continue; + } + + if (!isSelected && selectionActive) { + end = i - 1; + break; + } + } + + if (start === undefined) { + return undefined; + } + + if (end === undefined) { + end = elements.length - 1; + } + + return { + start: start, + end: end, + }; + } + + async function getSelectedCells(): Promise { + const activeSection = await driver.find('.active_section'); + + const colSelection = await getSelectionRange(activeSection, '.column_names .column_name'); + if (!colSelection) { + return undefined; + } + + const rowSelection = await getSelectionRange(activeSection, '.gridview_row .gridview_data_row_num'); + if (!rowSelection) { + // Edge case if only a cell in the "new" row is selected + // Not relevant for our tests + return undefined; + } + + return { + colStart: colSelection.start, + colEnd: colSelection.end, + rowStart: rowSelection.start, + rowEnd: rowSelection.end, + }; + } + + async function assertCellSelection(expected: CellSelection|undefined) { + const currentSelection = await getSelectedCells(); + assert.deepEqual(currentSelection, expected); + } + + + it('Shift+Up extends the selection up', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 0, rowEnd: 1}); + }); + + it('Shift+Down extends the selection down', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 2}); + }); + + it('Shift+Left extends the selection left', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 0, colEnd: 1, rowStart: 1, rowEnd: 1}); + }); + + it('Shift+Right extends the selection right', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT)); + await assertCellSelection({colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 1}); + }); + + it('Shift+Right + Shift+Left leads to the initial selection', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT)); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 1}); + }); + + it('Shift+Up + Shift+Down leads to the initial selection', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.UP)); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 1}); + }); + + it('Ctrl+Shift+Up extends the selection blockwise up', async function () { + await gu.getCell(5, 7).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 6}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 2, rowEnd: 6}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 0, rowEnd: 6}); + }); + + it('Ctrl+Shift+Down extends the selection blockwise down', async function () { + await gu.getCell(5, 5).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 6}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 8}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 10}); + }); + + it('Ctrl+Shift+Left extends the selection blockwise left', async function () { + await gu.getCell(6, 5).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 4, colEnd: 6, rowStart: 4, rowEnd: 4}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 2, colEnd: 6, rowStart: 4, rowEnd: 4}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 0, colEnd: 6, rowStart: 4, rowEnd: 4}); + }); + + it('Ctrl+Shift+Right extends the selection blockwise right', async function () { + await gu.getCell(4, 5).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT)); + await assertCellSelection({colStart: 4, colEnd: 6, rowStart: 4, rowEnd: 4}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT)); + await assertCellSelection({colStart: 4, colEnd: 8, rowStart: 4, rowEnd: 4}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT)); + await assertCellSelection({colStart: 4, colEnd: 10, rowStart: 4, rowEnd: 4}); + }); + + it('Ctrl+Shift+* extends the selection until all the next cells are empty', async function () { + await gu.getCell(3, 7).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT)); + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 3, colEnd: 4, rowStart: 2, rowEnd: 6}); + + await gu.getCell(4, 7).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT)); + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 3, colEnd: 4, rowStart: 4, rowEnd: 6}); + }); + });