diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 57b32838..4111a43a 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -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...', { diff --git a/app/server/lib/MinIOExternalStorage.ts b/app/server/lib/MinIOExternalStorage.ts index fe70ea4b..73258887 100644 --- a/app/server/lib/MinIOExternalStorage.ts +++ b/app/server/lib/MinIOExternalStorage.ts @@ -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: { - endPoint: string, - port?: number, - useSSL?: boolean, - accessKey: string, - secretKey: string, - region: string - }, private _batchSize?: number) { - this._s3 = new minio.Client(options) as MinIOClient; + constructor( + public bucket: string, + public options: { + endPoint: string, + port?: number, + useSSL?: boolean, + accessKey: string, + secretKey: string, + region: string + }, + 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!), })); } diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 766dee29..05f94bfd 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -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" } } diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index 91382661..3cb63397 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -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 l’aide", "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 d’espace", @@ -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 d’ancrage 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" } } diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index 48f26cb2..edf47b02 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -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" } } diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index f9ebde05..cd4e2497 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -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": "Поиск по всем таблицам" } } diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 9409f292..7a1ff4ac 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -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 { await driver.wait(() => driver.find('.test-dm-doclist').isDisplayed(), 2000); } -export async function waitToPass(check: () => Promise, 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 { 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 { - 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 { - 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. */ diff --git a/test/nbrowser/gristWebDriverUtils.ts b/test/nbrowser/gristWebDriverUtils.ts new file mode 100644 index 00000000..ed1b394d --- /dev/null +++ b/test/nbrowser/gristWebDriverUtils.ts @@ -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 { + 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 { + 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, 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; +} diff --git a/test/server/lib/MinIOExternalStorage.ts b/test/server/lib/MinIOExternalStorage.ts new file mode 100644 index 00000000..915484a2 --- /dev/null +++ b/test/server/lib/MinIOExternalStorage.ts @@ -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 { + 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); + }); + }); +}); \ No newline at end of file