(core) updates from grist-core

This commit is contained in:
Paul Fitzpatrick 2023-07-23 11:53:20 -04:00
commit 7256e0c245
9 changed files with 645 additions and 234 deletions

View File

@ -2138,6 +2138,9 @@ export class ActiveDoc extends EventEmitter {
private async _checkDataSizeLimitRatio(docSession: OptDocSession | null) {
const start = Date.now();
if (!this.docStorage.isInitialized()) {
return;
}
const dataSizeBytes = await this._updateDataSize();
const timeToMeasure = Date.now() - start;
log.rawInfo('Data size from dbstat...', {

View File

@ -25,19 +25,21 @@ type MinIOBucketItemStat = minio.BucketItemStat & {
* will work with MinIO and other S3-compatible storage.
*/
export class MinIOExternalStorage implements ExternalStorage {
private _s3: MinIOClient;
// Specify bucket to use, and optionally the max number of keys to request
// in any call to listObjectVersions (used for testing)
constructor(public bucket: string, public options: {
constructor(
public bucket: string,
public options: {
endPoint: string,
port?: number,
useSSL?: boolean,
accessKey: string,
secretKey: string,
region: string
}, private _batchSize?: number) {
this._s3 = new minio.Client(options) as MinIOClient;
},
private _batchSize?: number,
private _s3 = new minio.Client(options) as MinIOClient
) {
}
public async exists(key: string, snapshotId?: string) {
@ -131,7 +133,10 @@ export class MinIOExternalStorage implements ExternalStorage {
(options?.includeDeleteMarkers || !(v as any).isDeleteMarker))
.map(v => ({
lastModified: v.lastModified.toISOString(),
snapshotId: (v as any).versionId!,
// Circumvent inconsistency of MinIO API with versionId by casting it to string
// PR to MinIO so we don't have to do that anymore:
// https://github.com/minio/minio-js/pull/1193
snapshotId: String((v as any).versionId!),
}));
}

View File

@ -898,7 +898,11 @@
"Error in the cell": "Error in the cell",
"Errors in all {{numErrors}} cells": "Errors in all {{numErrors}} cells",
"Errors in {{numErrors}} of {{numCells}} cells": "Errors in {{numErrors}} of {{numCells}} cells",
"editingFormula is required": "editingFormula is required"
"editingFormula is required": "editingFormula is required",
"Enter formula or {{button}}.": "Enter formula or {{button}}.",
"Enter formula.": "Enter formula.",
"Expand Editor": "Expand Editor",
"use AI Assistant": "use AI Assistant"
},
"HyperLinkEditor": {
"[link label] url": "[link label] URL"
@ -1037,7 +1041,20 @@
"Regenerate": "Regenerate",
"Save": "Save",
"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.",
"Tips": "Tips"
"Tips": "Tips",
"AI Assistant": "AI Assistant",
"Apply": "Apply",
"Cancel": "Cancel",
"Clear Conversation": "Clear Conversation",
"Code View": "Code View",
"Hi, I'm the Grist Formula AI Assistant.": "Hi, I'm the Grist Formula AI Assistant.",
"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.": "I can only help with formulas. I cannot build tables, columns, and views, or write access rules.",
"Learn more": "Learn more",
"Press Enter to apply suggested formula.": "Press Enter to apply suggested formula.",
"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?"
},
"GridView": {
"Click to insert": "Click to insert"
@ -1133,5 +1150,12 @@
"No data": "No data",
"No row selected in {{title}}": "No row selected in {{title}}",
"Not all data is shown": "Not all data is shown"
},
"FloatingEditor": {
"Collapse Editor": "Collapse Editor"
},
"FloatingPopup": {
"Maximize": "Maximize",
"Minimize": "Minimize"
}
}

View File

@ -40,7 +40,9 @@
"Remove column {{- colId }} from {{- tableId }} rules": "Supprimer la colonne {{-colId}} des règles de la table {{-tableId}}",
"Seed rules": "Règles par défaut",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ajouter automatiquement une règle donnant tous les droits au groupe OWNER.",
"Permission to edit document structure": "Droits d'édition de la structure"
"Permission to edit document structure": "Droits d'édition de la structure",
"This default should be changed if editors' access is to be limited. ": "Cette valeur par défaut doit être modifiée si l'on souhaite limiter l'accès des éditeurs. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Autorise les éditeurs à éditer la structure (modifier/supprimer des tables, colonnes, mises en page...) et à écrire des formules, ce qui donne accès à l'ensemble des données sans prendre en compte d'éventuelles restrictions de droits de lecture."
},
"AccountPage": {
"Account settings": "Paramètres du compte",
@ -71,7 +73,11 @@
"Switch Accounts": "Changer de compte",
"Accounts": "Comptes",
"Add Account": "Ajouter un compte",
"Sign Out": "Se déconnecter"
"Sign Out": "Se déconnecter",
"Upgrade Plan": "Version Premium",
"Support Grist": "Centre d'aide",
"Billing Account": "Facturation",
"Activation": "Activer"
},
"ActionLog": {
"Action Log failed to load": "Impossible de charger le journal des actions",
@ -472,7 +478,8 @@
"Ask for help": "Demander de laide",
"Notifications": "Notifications",
"Give feedback": "Donnez votre avis",
"No notifications": "Aucune notification"
"No notifications": "Aucune notification",
"Manage billing": "Gérer la facturation"
},
"OnBoardingPopups": {
"Finish": "Terminer",
@ -581,7 +588,8 @@
"Download": "Télécharger",
"Export CSV": "Exporter en CSV",
"Export XLSX": "Exporter en XLSX",
"Send to Google Drive": "Envoyer vers Google Drive"
"Send to Google Drive": "Envoyer vers Google Drive",
"Share": "Partager"
},
"SiteSwitcher": {
"Switch Sites": "Changer despace",
@ -590,7 +598,7 @@
"SortConfig": {
"Add Column": "Ajouter une colonne",
"Update Data": "Mettre à jour les données",
"Use choice position": "Use choice position",
"Use choice position": "Utiliser l'ordre des choix",
"Natural sort": "Trier",
"Empty values last": "Valeurs vides en dernier",
"Search Columns": "Rechercher"
@ -1011,5 +1019,29 @@
"WebhookPage": {
"Clear Queue": "Effacer la file d'attente",
"Webhook Settings": "Paramètres des points dancrage Web"
},
"FormulaAssistant": {
"Grist's AI Formula Assistance. ": "Assistance des formules de l'IA de Grist ",
"Ask the bot.": "Demandez au bot.",
"Function List": "Liste des fonctions",
"Tips": "Conseils"
},
"SupportGristNudge": {
"Help Center": "Centre d'aide",
"Close": "Fermer",
"Contribute": "Contribuer",
"Support Grist": "Support Grist"
},
"GridView": {
"Click to insert": "Cliquer pour insérer"
},
"SupportGristPage": {
"GitHub": "GitHub",
"Help Center": "Centre d'aide",
"Home": "Accueil",
"Sponsor Grist Labs on GitHub": "Sponsoriser Grist Labs sur GitHub"
},
"buildViewSectionDom": {
"No data": "Aucune donnée"
}
}

View File

@ -40,7 +40,9 @@
"View As": "Ver como",
"Seed rules": "Regras de propagação",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.",
"Permission to edit document structure": "Permissão para editar a estrutura do documento"
"Permission to edit document structure": "Permissão para editar a estrutura do documento",
"This default should be changed if editors' access is to be limited. ": "Esse padrão deve ser alterado se o acesso dos editores for limitado. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura."
},
"AccountPage": {
"API": "API",
@ -636,7 +638,8 @@
"Send to Google Drive": "Enviar ao Google Drive",
"Show in folder": "Mostrar na pasta",
"Unsaved": "Não Salvo",
"Work on a Copy": "Trabalho em uma cópia"
"Work on a Copy": "Trabalho em uma cópia",
"Share": "Compartilhar"
},
"SiteSwitcher": {
"Create new team site": "Criar novo site de equipe",
@ -857,7 +860,8 @@
"Find Next ": "Encontrar Próximo ",
"Find Previous ": "Encontrar Anterior ",
"No results": "Sem resultados",
"Search in document": "Procurar no documento"
"Search in document": "Procurar no documento",
"Search": "Procurar"
},
"sendToDrive": {
"Sending file to Google Drive": "Enviando arquivo ao Google Drive"
@ -1043,7 +1047,9 @@
"relational": "relacionais",
"Unpin to hide the the button while keeping the filter.": "Desfixe para ocultar o botão enquanto mantém o filtro.",
"Anchor Links": "Links de âncora",
"Custom Widgets": "Widgets personalizados"
"Custom Widgets": "Widgets personalizados",
"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.": "Para criar um link âncora que leve o usuário a uma célula específica, clique em uma linha e pressione {{shortcut}}.",
"You can choose one of our pre-made widgets or embed your own by providing its full URL.": "Você pode escolher um dos nossos widgets pré-feitos ou incorporar seu próprio, fornecendo sua URL completa."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIÇÃO"
@ -1136,5 +1142,60 @@
"No data": "Sem dados",
"No row selected in {{title}}": "Nenhuma linha selecionada em {{title}}",
"Not all data is shown": "Nem todos os dados são mostrados"
},
"DescriptionTextArea": {
"DESCRIPTION": "DESCRIÇÃO"
},
"UserManager": {
"Add {{member}} to your team": "Adicionar {{member}} à sua equipe",
"Allow anyone with the link to open.": "Permita que qualquer pessoa com o link abra.",
"Anyone with link ": "Qualquer pessoa com link ",
"Cancel": "Cancelar",
"Close": "Fechar",
"Collaborator": "Colaborador",
"Confirm": "Confirmar",
"Copy Link": "Copiar link",
"Create a team to share with more people": "Crie uma equipe para compartilhar com mais pessoas",
"Guest": "Convidado",
"Invite multiple": "Convidar vários",
"Invite people to {{resourceType}}": "Convidar pessoas para {{resourceType}}",
"Manage members of team site": "Gerenciar membros do site da equipe",
"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Nenhum acesso padrão permite que o acesso seja concedido a documentos ou espaços de trabalho individuais, em vez do site de equipe completo.",
"On": "Ligado",
"Open Access Rules": "Regras de acesso aberto",
"Outside collaborator": "Colaborador externo",
"Public Access": "Acesso Público",
"Public access": "Acesso Público",
"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Acesso público herdado de {{parent}}. Para remover, defina a opção 'Herdar acesso' para 'Nenhum'.",
"Public access: ": "Acesso público: ",
"Remove my access": "Remover meu acesso",
"Save & ": "Salvar & ",
"Team member": "Membro da equipe",
"User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.": "O usuário herda as permissões de {{parent})}. Para remover, defina a opção 'Herdar acesso' para 'Nenhum'.",
"User may not modify their own access.": "O usuário não pode modificar seu próprio acesso.",
"Your role for this team site": "Seu papel para este site de equipe",
"Your role for this {{resourceType}}": "Seu papel para este {{resourceType}}",
"free collaborator": "colaborador livre",
"member": "membro",
"team site": "site da equipe",
"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}}.": "Depois de remover seu próprio acesso, você não poderá recuperá-lo sem a ajuda de outra pessoa com acesso suficiente ao {{resourceType}}.",
"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "O usuário tem acesso de visualização a {{resource}} resultante do acesso definido manualmente aos recursos internos. Se removido aqui, esse usuário perderá o acesso aos recursos internos.",
"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "O usuário herda as permissões de {{parent}}. Para remover, defina a opção 'Herdar acesso' para 'Nenhum'.",
"Grist support": "Suporte Grist",
"Link copied to clipboard": "Link copiado para a área de transferência",
"guest": "convidado",
"Off": "Desligado",
"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}}.": "Depois de remover seu próprio acesso, você não poderá recuperá-lo sem a ajuda de outra pessoa com acesso suficiente ao {{name}}.",
"{{collaborator}} limit exceeded": "Limite de {{collaborator}} excedido",
"{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitAt}} de {{limitTop}} {{collaborator}}s",
"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Nenhum acesso padrão permite que o acesso seja concedido a documentos ou espaços de trabalho individuais, em vez do site de equipe completo.",
"You are about to remove your own access to this {{resourceType}}": "Você está prestes a remover seu próprio acesso a este {{resourceType}}"
},
"SearchModel": {
"Search all tables": "Procurar todas as tabelas",
"Search all pages": "Procurar todas as páginas"
},
"searchDropdown": {
"Search": "Procurar"
}
}

View File

@ -34,7 +34,9 @@
"Type a message...": "Введите сообщение…",
"Saved": "Сохранено",
"Permission to edit document structure": "Разрешение на редактирование структуры документа",
"When adding table rules, automatically add a rule to grant OWNER full access.": "При добавлении правил таблицы, автоматически добавить правило для предоставления ВЛАДЕЛЬЦУ полного доступа."
"When adding table rules, automatically add a rule to grant OWNER full access.": "При добавлении правил таблицы, автоматически добавить правило для предоставления ВЛАДЕЛЬЦУ полного доступа.",
"This default should be changed if editors' access is to be limited. ": "Это значение по умолчанию следует изменить, если требуется ограничить доступ редакторов. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Позволяет редакторам редактировать структуру (например, изменять и удалять таблицы, столбцы, макеты) и писать формулы, которые предоставляют доступ ко всем данным независимо от ограничений на чтение."
},
"ACUserManager": {
"Enter email address": "Введите адрес электронной почты",
@ -174,7 +176,11 @@
"Sign in": "Войти",
"Toggle Mobile Mode": "Переключить мобильный режим",
"Pricing": "Цены",
"Sign Out": "Выход"
"Sign Out": "Выход",
"Support Grist": "Поддержка Grist",
"Upgrade Plan": "Обновить Подписку",
"Activation": "Активация",
"Billing Account": "Расчетный счет"
},
"ActionLog": {
"Table {{tableId}} was subsequently removed in action #{{actionNum}}": "Таблица {{tableId}} впоследствии была удалена в действии #{{actionNum}}",
@ -271,7 +277,8 @@
"Save Copy": "Сохранить копию",
"Send to Google Drive": "Отправить в Google Диск",
"Save Document": "Сохранить документ",
"Work on a Copy": "Работа над копией"
"Work on a Copy": "Работа над копией",
"Share": "Поделиться"
},
"SortConfig": {
"Search Columns": "Поиск по столбцам",
@ -559,7 +566,8 @@
"Notifications": "Уведомления",
"Renew": "Продлить",
"Go to your free personal site": "Перейдите на свой бесплатный личный сайт",
"Upgrade Plan": "Обновить тариф"
"Upgrade Plan": "Обновить тариф",
"Manage billing": "Управление платежами"
},
"OpenVideoTour": {
"Grist Video Tour": "Видео-тур по Grist",
@ -869,7 +877,8 @@
"Find Next ": "Найти далее ",
"No results": "Нет результатов",
"Find Previous ": "Найти предыдущий ",
"Search in document": "Поиск в документе"
"Search in document": "Поиск в документе",
"Search": "Поиск"
},
"sendToDrive": {
"Sending file to Google Drive": "Отправка файла в Google Drive"
@ -974,7 +983,9 @@
"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Используйте 𝚺 значок для создания сводных таблиц для итогов или промежуточных итогов.",
"relational": "реляционный",
"Anchor Links": "Якорные ссылки",
"Custom Widgets": "Пользовательские виджеты"
"Custom Widgets": "Пользовательские виджеты",
"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.": "Чтобы создать якорную ссылку, которая приведет пользователя к определенной ячейке, щелкните по строке и нажмите {{shortcut}}.",
"You can choose one of our pre-made widgets or embed your own by providing its full URL.": "Вы можете выбрать один из наших готовых виджетов или встроить свой собственный, указав его полный URL-адрес."
},
"DescriptionConfig": {
"DESCRIPTION": "ОПИСАНИЕ"
@ -1035,5 +1046,92 @@
"Welcome back": "С возвращением",
"You can always switch sites using the account menu.": "Вы всегда можете переключиться с одного сайта на другой, используя меню учетной записи.",
"You have access to the following Grist sites.": "У вас есть доступ к следующим сайтам Grist."
},
"UserManager": {
"Add {{member}} to your team": "Добавить {{member}} в вашу команду",
"Anyone with link ": "Любой по ссылке ",
"Cancel": "Отмена",
"Close": "Закрыть",
"Collaborator": "Соавтор",
"Confirm": "Подтвердить",
"Copy Link": "Скопировать Ссылку",
"Guest": "Гость",
"Invite multiple": "Пригласить несколько",
"Manage members of team site": "Управление участниками группового сайта",
"On": "Включено",
"Open Access Rules": "Открыть Правила Доступа",
"Outside collaborator": "Сторонний соавтор",
"Public Access": "Публичный Доступ",
"Public access": "Публичный доступ",
"Public access: ": "Публичный доступ: ",
"Remove my access": "Удалить мой доступ",
"Save & ": "Сохранить & ",
"Team member": "Участник команды",
"User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.": "Пользователь наследует разрешения от {{parent})}. Для удаления, установите параметр 'Наследовать доступ' в 'None'.",
"User may not modify their own access.": "Пользователь не может изменять свой собственный доступ.",
"Your role for this team site": "Ваша роль для этого группового сайта",
"Your role for this {{resourceType}}": "Ваша роль для этого {{resourceType}}",
"free collaborator": "свободный соавтор",
"member": "участник",
"team site": "групповой сайт",
"{{collaborator}} limit exceeded": "{{collaborator}} лимит превышен",
"{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitAt}} из {{limitTop}} {{collaborator}}s",
"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "У пользователя есть доступ к просмотру {{resource}} в результате установленного вручную доступа к внутренним ресурсам. Если удалить его здесь, этот пользователь потеряет доступ к внутренним ресурсам.",
"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Пользователь наследует разрешения от {{parent}}. Чтобы удалить, установите для параметра 'Наследовать доступ' значение 'None'.",
"Allow anyone with the link to open.": "Разрешить открывать всем, у кого есть ссылка.",
"Grist support": "Grist поддержка",
"Invite people to {{resourceType}}": "Пригласите людей в {{resourceType}}",
"Create a team to share with more people": "Создайте команду, чтобы поделиться с большим количеством людей",
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Отсутствие доступа по умолчанию позволяет предоставлять доступ к отдельным документам или рабочим областям, а не ко всему групповому сайту.",
"Off": "Выключено",
"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}}.": "После того, как вы удалили свой собственный доступ, вы не сможете вернуть его без посторонней помощи от кого-то другого, имеющего достаточный доступ к {{name}}.",
"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Публичный доступ унаследован от {{parent}}. Чтобы удалить, установите для параметра «Наследовать доступ» значение 'None'.",
"guest": "гость",
"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Отсутствие доступа по умолчанию позволяет предоставлять доступ к отдельным документам или рабочим областям, а не ко всему групповому сайту.",
"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}}.": "После того как вы удалили свой собственный доступ, вы не сможете получить его обратно без помощи кого-то еще с достаточным доступом к {{resourceType}}.",
"You are about to remove your own access to this {{resourceType}}": "Вы собираетесь лишить себя доступа к {{resourceType}}"
},
"SupportGristNudge": {
"Close": "Закрыть",
"Help Center": "Центр помощи",
"Opt in to Telemetry": "Включить телеметрию",
"Support Grist": "Поддержка Grist",
"Support Grist page": "Страница поддержки Grist",
"Contribute": "Contribute",
"Opted In": "Подключено"
},
"SupportGristPage": {
"GitHub": "GitHub",
"GitHub Sponsors page": "GitHub Спонсорская страница",
"Help Center": "Центр помощи",
"Home": "Домой",
"Manage Sponsorship": "Управление спонсорством",
"Support Grist": "Поддержать Grist",
"Telemetry": "Телеметрия",
"This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Этот экземпляр отключен от телеметрии. Только администратор сайта имеет право изменить это.",
"You can opt out of telemetry at any time from this page.": "Вы можете отказаться от телеметрии в любое время на этой странице.",
"You have opted in to telemetry. Thank you!": "Вы включили телеметрию. Спасибо!",
"You have opted out of telemetry.": "Вы отказались от телеметрии.",
"Opt in to Telemetry": "Включить телеметрию",
"Opt out of Telemetry": "Отказаться от телеметрии",
"Sponsor Grist Labs on GitHub": "Спонсор Grist Labs на GitHub",
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "В этом экземпляре включена телеметрия. Только администратор сайта может изменить это.",
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Мы собираем только статистику использования, как описано в нашей {{link}}, никогда содержимое документов."
},
"buildViewSectionDom": {
"No data": "Нет данных",
"No row selected in {{title}}": "Нет выбранных строк в {{title}}",
"Not all data is shown": "Не все данные отображаются"
},
"DescriptionTextArea": {
"DESCRIPTION": "ОПИСАНИЕ"
},
"searchDropdown": {
"Search": "Поиск"
},
"SearchModel": {
"Search all pages": "Искать на всех страницах",
"Search all tables": "Поиск по всем таблицам"
}
}

View File

@ -22,6 +22,7 @@ import { Organization } from 'app/gen-server/entity/Organization';
import { Product } from 'app/gen-server/entity/Product';
import { create } from 'app/server/lib/create';
import { GristWebDriverUtils, PageWidgetPickerOptions, WindowDimensions } from 'test/nbrowser/gristWebDriverUtils';
import { HomeUtil } from 'test/nbrowser/homeUtil';
import { server } from 'test/nbrowser/testServer';
import { Cleanup } from 'test/nbrowser/testUtils';
@ -49,6 +50,7 @@ export function currentDriver() { return driver; }
export function setDriver(customDriver?: WebDriver) { _driver = customDriver; }
const homeUtil = new HomeUtil(testUtils.fixturesRoot, server);
const webdriverUtils = new GristWebDriverUtils(driver);
export const createNewDoc = homeUtil.createNewDoc.bind(homeUtil);
// importFixturesDoc has a custom implementation that supports 'load' flag.
@ -67,6 +69,17 @@ export const checkLoginPage = homeUtil.checkLoginPage.bind(homeUtil);
export const checkGristLoginPage = homeUtil.checkGristLoginPage.bind(homeUtil);
export const copyDoc = homeUtil.copyDoc.bind(homeUtil);
export const isSidePanelOpen = webdriverUtils.isSidePanelOpen.bind(webdriverUtils);
export const waitForServer = webdriverUtils.waitForServer.bind(webdriverUtils);
export const waitForSidePanel = webdriverUtils.waitForSidePanel.bind(webdriverUtils);
export const toggleSidePanel = webdriverUtils.toggleSidePanel.bind(webdriverUtils);
export const getWindowDimensions = webdriverUtils.getWindowDimensions.bind(webdriverUtils);
export const addNewSection = webdriverUtils.addNewSection.bind(webdriverUtils);
export const selectWidget = webdriverUtils.selectWidget.bind(webdriverUtils);
export const dismissBehavioralPrompts = webdriverUtils.dismissBehavioralPrompts.bind(webdriverUtils);
export const toggleSelectable = webdriverUtils.toggleSelectable.bind(webdriverUtils);
export const waitToPass = webdriverUtils.waitToPass.bind(webdriverUtils);
export const fixturesRoot: string = testUtils.fixturesRoot;
// it is sometimes useful in debugging to turn off automatic cleanup of docs and workspaces.
@ -780,25 +793,6 @@ export async function waitForDocMenuToLoad(): Promise<void> {
await driver.wait(() => driver.find('.test-dm-doclist').isDisplayed(), 2000);
}
export async function waitToPass(check: () => Promise<void>, timeMs: number = 4000) {
try {
let delay: number = 10;
await driver.wait(async () => {
try {
await check();
} catch (e) {
// Throttle operations a little bit.
await driver.sleep(delay);
if (delay < 50) { delay += 10; }
return false;
}
return true;
}, timeMs);
} catch (e) {
await check();
}
}
// Checks if we are configured to store docs in s3, and returns access to s3 if so.
// For this to be useful in tests against deployments, s3-related env variables should
// be set to match the deployment.
@ -944,23 +938,6 @@ export async function waitForLabelInput(): Promise<void> {
await driver.wait(async () => (await driver.findWait('.test-column-title-label', 100).hasFocus()), 300);
}
/**
* Waits for all pending comm requests from the client to the doc worker to complete. This taps into
* Grist's communication object in the browser to get the count of pending requests.
*
* Simply call this after some request has been made, and when it resolves, you know that request
* has been processed.
* @param optTimeout: Timeout in ms, defaults to 2000.
*/
export async function waitForServer(optTimeout: number = 2000) {
await driver.wait(() => driver.executeScript(
"return window.gristApp && (!window.gristApp.comm || !window.gristApp.comm.hasActiveRequests())"
+ " && window.gristApp.testNumPendingApiRequests() === 0",
optTimeout,
"Timed out waiting for server requests to complete"
));
}
/**
* Sends UserActions using client api from the browser.
*/
@ -1085,18 +1062,6 @@ export async function addNewTable(name?: string) {
await waitForServer();
}
export interface PageWidgetPickerOptions {
tableName?: string;
/** Optional pattern of SELECT BY option to pick. */
selectBy?: RegExp|string;
/** Optional list of patterns to match Group By columns. */
summarize?: (RegExp|string)[];
/** If true, configure the widget selection without actually adding to the page. */
dontAdd?: boolean;
/** If true, dismiss any tooltips that are shown. */
dismissTips?: boolean;
}
// Add a new page using the 'Add New' menu and wait for the new page to be shown.
export async function addNewPage(
typeRe: RegExp|'Table'|'Card'|'Card List'|'Chart'|'Custom',
@ -1115,97 +1080,11 @@ export async function addNewPage(
await driver.wait(async () => (await driver.getCurrentUrl()) !== url, 2000);
}
type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom';
// Add a new widget to the current page using the 'Add New' menu.
export async function addNewSection(
typeRe: RegExp|SectionTypes, tableRe: RegExp|string, options?: PageWidgetPickerOptions
) {
// Click the 'Add widget to page' entry in the 'Add New' menu
await driver.findWait('.test-dp-add-new', 2000).doClick();
await driver.findWait('.test-dp-add-widget-to-page', 500).doClick();
// add widget
await selectWidget(typeRe, tableRe, options);
}
export async function openAddWidgetToPage() {
await driver.findWait('.test-dp-add-new', 2000).doClick();
await driver.findWait('.test-dp-add-widget-to-page', 2000).doClick();
}
// Select type and table that matches respectively typeRe and tableRe and save. The widget picker
// must be already opened when calling this function.
export async function selectWidget(
typeRe: RegExp|string,
tableRe: RegExp|string = '',
options: PageWidgetPickerOptions = {}
) {
if (options.dismissTips) { await dismissBehavioralPrompts(); }
// select right type
await driver.findContent('.test-wselect-type', typeRe).doClick();
if (options.dismissTips) { await dismissBehavioralPrompts(); }
if (tableRe) {
const tableEl = driver.findContent('.test-wselect-table', tableRe);
// unselect all selected columns
for (const col of (await driver.findAll('.test-wselect-column[class*=-selected]'))) {
await col.click();
}
// let's select table
await tableEl.click();
if (options.dismissTips) { await dismissBehavioralPrompts(); }
const pivotEl = tableEl.find('.test-wselect-pivot');
if (await pivotEl.isPresent()) {
await toggleSelectable(pivotEl, Boolean(options.summarize));
}
if (options.summarize) {
for (const columnEl of await driver.findAll('.test-wselect-column')) {
const label = await columnEl.getText();
// TODO: Matching cols with regexp calls for trouble and adds no value. I think function should be
// rewritten using string matching only.
const goal = Boolean(options.summarize.find(r => label.match(r)));
await toggleSelectable(columnEl, goal);
}
}
if (options.selectBy) {
// select link
await driver.find('.test-wselect-selectby').doClick();
await driver.findContent('.test-wselect-selectby option', options.selectBy).doClick();
}
}
if (options.dontAdd) {
return;
}
// add the widget
await driver.find('.test-wselect-addBtn').doClick();
// if we selected a new table, there will be a popup for a name
const prompts = await driver.findAll(".test-modal-prompt");
const prompt = prompts[0];
if (prompt) {
if (options.tableName) {
await prompt.doClear();
await prompt.click();
await driver.sendKeys(options.tableName);
}
await driver.find(".test-modal-confirm").click();
}
await waitForServer();
}
export type WidgetType = 'Table' | 'Card' | 'Card List' | 'Chart' | 'Custom';
@ -1216,17 +1095,6 @@ export async function changeWidget(type: WidgetType) {
await waitForServer();
}
/**
* Toggle elem if not selected. Expects elem to be clickable and to have a class ending with
* -selected when selected.
*/
async function toggleSelectable(elem: WebElement, goal: boolean) {
const isSelected = await elem.matches('[class*=-selected]');
if (goal !== isSelected) {
await elem.click();
}
}
/**
* Rename the given page to a new name. The oldName can be a full string name or a RegExp.
*/
@ -1396,38 +1264,6 @@ export async function checkForErrors() {
assert.deepEqual(errors, []);
}
export function isSidePanelOpen(which: 'right'|'left'): Promise<boolean> {
return driver.find(`.test-${which}-panel`).matches('[class*=-open]');
}
/*
* Toggles (opens or closes) the right or left panel and wait for the transition to complete. An optional
* argument can specify the desired state.
*/
export async function toggleSidePanel(which: 'right'|'left', goal: 'open'|'close'|'toggle' = 'toggle') {
if ((goal === 'open' && await isSidePanelOpen(which)) ||
(goal === 'close' && !await isSidePanelOpen(which))) {
return;
}
// Adds '-ns' when narrow screen
const suffix = (await getWindowDimensions()).width < 768 ? '-ns' : '';
// click the opener and wait for the duration of the transition
await driver.find(`.test-${which}-opener${suffix}`).doClick();
await waitForSidePanel();
}
export async function waitForSidePanel() {
// 0.4 is the duration of the transition setup in app/client/ui/PagePanels.ts for opening the
// side panes
const transitionDuration = 0.4;
// let's add an extra delay of 0.1 for even more robustness
const delta = 0.1;
await driver.sleep((transitionDuration + delta) * 1000);
}
/**
* Opens a Creator Panel on Widget/Table settings tab.
*/
@ -2450,19 +2286,6 @@ export async function selectColumn(col: string) {
await getColumnHeader({col}).click();
}
export interface WindowDimensions {
width: number;
height: number;
}
/**
* Gets browser window dimensions.
*/
export async function getWindowDimensions(): Promise<WindowDimensions> {
const {width, height} = await driver.manage().window().getRect();
return {width, height};
}
/**
* Sets browser window dimensions.
*/
@ -3014,21 +2837,6 @@ export async function refreshDismiss() {
await waitForDocToLoad();
}
/**
* Dismisses all behavioral prompts that are present.
*/
export async function dismissBehavioralPrompts() {
let i = 0;
const max = 10;
// Keep dismissing prompts until there are no more, up to a maximum of 10 times.
while (i < max && await driver.find('.test-behavioral-prompt').isPresent()) {
await driver.find('.test-behavioral-prompt-dismiss').click();
await waitForServer();
i += 1;
}
}
/**
* Dismisses any tutorial card that might be active.
*/

View File

@ -0,0 +1,223 @@
/**
* Utilities that simplify writing browser tests against Grist, which
* have only mocha-webdriver as a code dependency. Separated out to
* make easier to borrow for grist-widget repo.
*
* If you are seeing this code outside the grist-core repo, please don't
* edit it, it is just a copy and local changes will prevent updating it
* easily.
*/
import { WebDriver, WebElement } from 'mocha-webdriver';
type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom';
export class GristWebDriverUtils {
public constructor(public driver: WebDriver) {
}
public isSidePanelOpen(which: 'right'|'left'): Promise<boolean> {
return this.driver.find(`.test-${which}-panel`).matches('[class*=-open]');
}
/**
* Waits for all pending comm requests from the client to the doc worker to complete. This taps into
* Grist's communication object in the browser to get the count of pending requests.
*
* Simply call this after some request has been made, and when it resolves, you know that request
* has been processed.
* @param optTimeout: Timeout in ms, defaults to 2000.
*/
public async waitForServer(optTimeout: number = 2000) {
await this.driver.wait(() => this.driver.executeScript(
"return window.gristApp && (!window.gristApp.comm || !window.gristApp.comm.hasActiveRequests())"
+ " && window.gristApp.testNumPendingApiRequests() === 0",
optTimeout,
"Timed out waiting for server requests to complete"
));
}
public async waitForSidePanel() {
// 0.4 is the duration of the transition setup in app/client/ui/PagePanels.ts for opening the
// side panes
const transitionDuration = 0.4;
// let's add an extra delay of 0.1 for even more robustness
const delta = 0.1;
await this.driver.sleep((transitionDuration + delta) * 1000);
}
/*
* Toggles (opens or closes) the right or left panel and wait for the transition to complete. An optional
* argument can specify the desired state.
*/
public async toggleSidePanel(which: 'right'|'left', goal: 'open'|'close'|'toggle' = 'toggle') {
if ((goal === 'open' && await this.isSidePanelOpen(which)) ||
(goal === 'close' && !await this.isSidePanelOpen(which))) {
return;
}
// Adds '-ns' when narrow screen
const suffix = (await this.getWindowDimensions()).width < 768 ? '-ns' : '';
// click the opener and wait for the duration of the transition
await this.driver.find(`.test-${which}-opener${suffix}`).doClick();
await this.waitForSidePanel();
}
/**
* Gets browser window dimensions.
*/
public async getWindowDimensions(): Promise<WindowDimensions> {
const {width, height} = await this.driver.manage().window().getRect();
return {width, height};
}
// Add a new widget to the current page using the 'Add New' menu.
public async addNewSection(
typeRe: RegExp|SectionTypes, tableRe: RegExp|string, options?: PageWidgetPickerOptions
) {
// Click the 'Add widget to page' entry in the 'Add New' menu
await this.driver.findWait('.test-dp-add-new', 2000).doClick();
await this.driver.findWait('.test-dp-add-widget-to-page', 500).doClick();
// add widget
await this.selectWidget(typeRe, tableRe, options);
}
// Select type and table that matches respectively typeRe and tableRe and save. The widget picker
// must be already opened when calling this function.
public async selectWidget(
typeRe: RegExp|string,
tableRe: RegExp|string = '',
options: PageWidgetPickerOptions = {}
) {
const driver = this.driver;
if (options.dismissTips) { await this.dismissBehavioralPrompts(); }
// select right type
await driver.findContent('.test-wselect-type', typeRe).doClick();
if (options.dismissTips) { await this.dismissBehavioralPrompts(); }
if (tableRe) {
const tableEl = driver.findContent('.test-wselect-table', tableRe);
// unselect all selected columns
for (const col of (await driver.findAll('.test-wselect-column[class*=-selected]'))) {
await col.click();
}
// let's select table
await tableEl.click();
if (options.dismissTips) { await this.dismissBehavioralPrompts(); }
const pivotEl = tableEl.find('.test-wselect-pivot');
if (await pivotEl.isPresent()) {
await this.toggleSelectable(pivotEl, Boolean(options.summarize));
}
if (options.summarize) {
for (const columnEl of await driver.findAll('.test-wselect-column')) {
const label = await columnEl.getText();
// TODO: Matching cols with regexp calls for trouble and adds no value. I think function should be
// rewritten using string matching only.
const goal = Boolean(options.summarize.find(r => label.match(r)));
await this.toggleSelectable(columnEl, goal);
}
}
if (options.selectBy) {
// select link
await driver.find('.test-wselect-selectby').doClick();
await driver.findContent('.test-wselect-selectby option', options.selectBy).doClick();
}
}
if (options.dontAdd) {
return;
}
// add the widget
await driver.find('.test-wselect-addBtn').doClick();
// if we selected a new table, there will be a popup for a name
const prompts = await driver.findAll(".test-modal-prompt");
const prompt = prompts[0];
if (prompt) {
if (options.tableName) {
await prompt.doClear();
await prompt.click();
await driver.sendKeys(options.tableName);
}
await driver.find(".test-modal-confirm").click();
}
await this.waitForServer();
}
/**
* Dismisses all behavioral prompts that are present.
*/
public async dismissBehavioralPrompts() {
let i = 0;
const max = 10;
// Keep dismissing prompts until there are no more, up to a maximum of 10 times.
while (i < max && await this.driver.find('.test-behavioral-prompt').isPresent()) {
await this.driver.find('.test-behavioral-prompt-dismiss').click();
await this.waitForServer();
i += 1;
}
}
/**
* Toggle elem if not selected. Expects elem to be clickable and to have a class ending with
* -selected when selected.
*/
public async toggleSelectable(elem: WebElement, goal: boolean) {
const isSelected = await elem.matches('[class*=-selected]');
if (goal !== isSelected) {
await elem.click();
}
}
public async waitToPass(check: () => Promise<void>, timeMs: number = 4000) {
try {
let delay: number = 10;
await this.driver.wait(async () => {
try {
await check();
} catch (e) {
// Throttle operations a little bit.
await this.driver.sleep(delay);
if (delay < 50) { delay += 10; }
return false;
}
return true;
}, timeMs);
} catch (e) {
await check();
}
}
}
export interface WindowDimensions {
width: number;
height: number;
}
export interface PageWidgetPickerOptions {
tableName?: string;
/** Optional pattern of SELECT BY option to pick. */
selectBy?: RegExp|string;
/** Optional list of patterns to match Group By columns. */
summarize?: (RegExp|string)[];
/** If true, configure the widget selection without actually adding to the page. */
dontAdd?: boolean;
/** If true, dismiss any tooltips that are shown. */
dismissTips?: boolean;
}

View File

@ -0,0 +1,157 @@
import * as minio from "minio";
import sinon from "sinon";
import * as stream from "node:stream";
import {MinIOExternalStorage} from "app/server/lib/MinIOExternalStorage";
import {assert} from "chai";
describe("MinIOExternalStorage", function () {
const sandbox = sinon.createSandbox();
const FakeClientClass = class extends minio.Client {
public listObjects(
bucket: string,
key: string,
recursive: boolean,
options?: {IncludeVersion?: boolean}
): minio.BucketStream<minio.BucketItem> {
return new stream.Readable();
}
};
const dummyBucket = 'some-bucket';
const dummyOptions = {
endPoint: 'some-endpoint',
accessKey: 'some-accessKey',
secretKey: 'some-secretKey',
region: 'some-region',
};
afterEach(function () {
sandbox.restore();
});
describe('versions()', function () {
function makeFakeStream(listedObjects: object[]) {
const fakeStream = new stream.Readable({objectMode: true});
const readSpy = sandbox.stub(fakeStream, "_read");
for (const [index, obj] of listedObjects.entries()) {
readSpy.onCall(index).callsFake(() => fakeStream.push(obj));
}
readSpy.onCall(listedObjects.length).callsFake(() => fakeStream.push(null));
return {fakeStream, readSpy};
}
it("should call listObjects with the right arguments", async function () {
const s3 = sandbox.createStubInstance(FakeClientClass);
const key = "some-key";
const expectedRecursive = false;
const expectedOptions = {IncludeVersion: true};
const {fakeStream} = makeFakeStream([]);
s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3);
const result = await extStorage.versions(key);
assert.deepEqual(result, []);
assert.isTrue(s3.listObjects.calledWith(dummyBucket, key, expectedRecursive, expectedOptions));
});
// This test can be removed once this PR is merged: https://github.com/minio/minio-js/pull/1193
// and when the minio-js version used as a dependency includes that patch.
//
// For more context: https://github.com/gristlabs/grist-core/pull/577
it("should return versionId's as string when return snapshotId is an integer", async function () {
// given
const s3 = sandbox.createStubInstance(FakeClientClass);
const key = "some-key";
const versionId = 123;
const lastModified = new Date();
const {fakeStream, readSpy} = makeFakeStream([
{
name: key,
lastModified,
versionId,
}
]);
s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3);
// when
const result = await extStorage.versions(key);
// then
assert.equal(readSpy.callCount, 2);
assert.deepEqual(result, [{
lastModified: lastModified.toISOString(),
snapshotId: String(versionId)
}]);
});
it("should include markers only when asked through options", async function () {
// given
const s3 = sandbox.createStubInstance(FakeClientClass);
const key = "some-key";
const lastModified = new Date();
const objectsFromS3 = [
{
name: key,
lastModified,
versionId: 'regular-version-uuid',
isDeleteMarker: false
},
{
name: key,
lastModified,
versionId: 'delete-marker-version-uuid',
isDeleteMarker: true
}
];
let {fakeStream} = makeFakeStream(objectsFromS3);
s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3);
// when
const result = await extStorage.versions(key);
// then
assert.deepEqual(result, [{
lastModified: lastModified.toISOString(),
snapshotId: objectsFromS3[0].versionId
}]);
// given
fakeStream = makeFakeStream(objectsFromS3).fakeStream;
s3.listObjects.returns(fakeStream);
// when
const resultWithDeleteMarkers = await extStorage.versions(key, {includeDeleteMarkers: true});
// then
assert.deepEqual(resultWithDeleteMarkers, [{
lastModified: lastModified.toISOString(),
snapshotId: objectsFromS3[0].versionId
}, {
lastModified: lastModified.toISOString(),
snapshotId: objectsFromS3[1].versionId
}]);
});
it("should reject when an error occurs while listing objects", function () {
// given
const s3 = sandbox.createStubInstance(FakeClientClass);
const key = "some-key";
const fakeStream = new stream.Readable({objectMode: true});
const error = new Error("dummy-error");
sandbox.stub(fakeStream, "_read")
.returns(fakeStream)
.callsFake(() => fakeStream.emit('error', error));
s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3);
// when
const result = extStorage.versions(key);
// then
return assert.isRejected(result, error);
});
});
});