From 561e499ab22649bce5e196bf8a638e625e23923f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:47:27 +0000 Subject: [PATCH 1/4] fix(plugin-map): degrade a failed map style to the record list with translated notices (WIP) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01HUu4jGnAzZqaB5qTG2fsX4 --- .changeset/25889-map-style-failure-notice.md | 14 ++ ui/packages/i18n/src/locales/ar.ts | 8 + ui/packages/i18n/src/locales/de.ts | 8 + ui/packages/i18n/src/locales/en.ts | 12 + ui/packages/i18n/src/locales/es.ts | 8 + ui/packages/i18n/src/locales/fr.ts | 8 + ui/packages/i18n/src/locales/ja.ts | 8 + ui/packages/i18n/src/locales/ko.ts | 8 + ui/packages/i18n/src/locales/pt.ts | 8 + ui/packages/i18n/src/locales/ru.ts | 8 + ui/packages/i18n/src/locales/zh.ts | 8 + .../src/ObjectMap.noticesI18n-25889.test.tsx | 73 ++++++ .../ObjectMap.styleFailureList-25889.test.tsx | 219 ++++++++++++++++++ .../src/ObjectMap.styleFallback.test.tsx | 5 +- ui/packages/plugin-map/src/ObjectMap.tsx | 106 ++++++--- .../ObjectMap.webgl2Controls-11819.test.tsx | 14 +- .../ObjectMap.webgl2Fallback-11819.test.tsx | 2 + 17 files changed, 480 insertions(+), 37 deletions(-) create mode 100644 .changeset/25889-map-style-failure-notice.md create mode 100644 ui/packages/plugin-map/src/ObjectMap.noticesI18n-25889.test.tsx create mode 100644 ui/packages/plugin-map/src/ObjectMap.styleFailureList-25889.test.tsx diff --git a/.changeset/25889-map-style-failure-notice.md b/.changeset/25889-map-style-failure-notice.md new file mode 100644 index 00000000000..97c7c30c239 --- /dev/null +++ b/.changeset/25889-map-style-failure-notice.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-map': patch +'@object-ui/i18n': patch +--- + +fix(plugin-map): a map whose style or tiles fail to load shows the record list with a translated notice, instead of a grey canvas under an English alert + +Clause-②: no +Metadata-protocol: none + +- **`@object-ui/plugin-map`:** when MapLibre reports a style or tile load failure (an intranet or proxied deployment that cannot reach the tile host, an unreachable style URL), the map view now removes the map and lists its records in its place, the same list a browser without WebGL2 already got: searchable, and each row opens the record the way its marker did. A "Try the map again" button brings the map back. The notice above the list keeps MapLibre's reason and names a remedy that exists: a view with its own map style is told to check that URL, and a view on the public default style is told to ask an administrator to set a map style for the view. It no longer says the markers are "listed in the search box" or asks for a "custom map style for production use" setting that does not exist. That notice, the WebGL2 notice, the list's label and empty text, and the "records excluded for missing or invalid coordinates" count now read the translation bundle, so a zh-CN console shows them in Chinese. +- **`@object-ui/i18n`:** eight new `element.map.*` keys in all ten packs. + +What to do: nothing. A deployment-wide map style setting is not part of this change. diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index ac057c1c960..a58b74c149b 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -5142,6 +5142,14 @@ const ar = { }, map: { sourceUnresolved: "تعذّر تحديد مصدر بيانات هذه الخريطة", + invalidCoordinatesExcluded: "السجلات المستبعدة من الخريطة بسبب إحداثيات مفقودة أو غير صالحة: {{excluded}}", + styleFailedConfigured: "تعذّر تحميل الخريطة ({{reason}}). السجلات مدرجة أدناه؛ تحقّق من عنوان URL لنمط الخريطة المحدد في طريقة العرض هذه.", + styleFailedDefault: "تعذّر تحميل الخريطة ({{reason}}). السجلات مدرجة أدناه؛ تعذّر تحميل مربعات الخريطة العامة الافتراضية هنا، لذا اطلب من المسؤول تعيين نمط خريطة لطريقة العرض هذه.", + styleFailedUnknownReason: "تعذّر تحميل نمط الخريطة أو مربعاتها", + webgl2Unavailable: "تعذّر تحميل الخريطة (هذا المتصفح لا يوفّر WebGL2 الذي تحتاجه الخريطة). السجلات مدرجة أدناه؛ فعّل تسريع الأجهزة أو استخدم متصفحًا يدعم WebGL2 لعرض الخريطة.", + retry: "إعادة محاولة تحميل الخريطة", + listLabel: "المواقع", + listEmpty: "لا توجد مواقع لعرضها.", }, timeline: { sourceUnresolved: "تعذّر تحديد مصدر بيانات هذا المخطط الزمني", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 2dca8512ae5..c8f048ff4b9 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4688,6 +4688,14 @@ const de = { }, map: { sourceUnresolved: "Die Datenquelle dieser Karte konnte nicht aufgelöst werden", + invalidCoordinatesExcluded: "Wegen fehlender oder ungültiger Koordinaten nicht auf der Karte angezeigte Datensätze: {{excluded}}", + styleFailedConfigured: "Die Karte konnte nicht geladen werden ({{reason}}). Die Datensätze sind unten aufgelistet; prüfen Sie die für diese Ansicht festgelegte Kartenstil-URL.", + styleFailedDefault: "Die Karte konnte nicht geladen werden ({{reason}}). Die Datensätze sind unten aufgelistet; die öffentlichen Standard-Kartenkacheln konnten hier nicht geladen werden. Bitten Sie Ihren Administrator, für diese Ansicht einen Kartenstil festzulegen.", + styleFailedUnknownReason: "Kartenstil oder Kacheln konnten nicht geladen werden", + webgl2Unavailable: "Die Karte konnte nicht geladen werden (dieser Browser unterstützt kein WebGL2, das die Karte benötigt). Die Datensätze sind unten aufgelistet; aktivieren Sie die Hardwarebeschleunigung oder verwenden Sie einen Browser mit WebGL2, um die Karte zu sehen.", + retry: "Karte erneut laden", + listLabel: "Orte", + listEmpty: "Keine Orte vorhanden.", }, timeline: { sourceUnresolved: "Die Datenquelle dieser Zeitleiste konnte nicht aufgelöst werden", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index ef2ecfa2f7c..a7595352a6e 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5479,6 +5479,18 @@ const en = { }, map: { sourceUnresolved: 'This map’s data source could not be resolved', + // objectstack#25889 — the map view's own notices. Both style failures + // fill `{{reason}}` with MapLibre's message, or `styleFailedUnknownReason` + // when it gave none; the excluded count reads "Label: N" so no key needs + // a plural family. + invalidCoordinatesExcluded: 'Records excluded from the map for missing or invalid coordinates: {{excluded}}', + styleFailedConfigured: 'Map failed to load ({{reason}}). The records are listed below; check the map style URL set on this view.', + styleFailedDefault: 'Map failed to load ({{reason}}). The records are listed below; the public default map tiles could not be loaded here, so ask your administrator to set a map style for this view.', + styleFailedUnknownReason: 'the map style or tiles could not be loaded', + webgl2Unavailable: 'Map failed to load (this browser does not provide WebGL2, which the map needs). The records are listed below; turn on hardware acceleration or use a browser with WebGL2 to see the map.', + retry: 'Try the map again', + listLabel: 'Locations', + listEmpty: 'No locations to list.', }, timeline: { sourceUnresolved: 'This timeline’s data source could not be resolved', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 464ece759f5..efd1f24cea2 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4801,6 +4801,14 @@ const es = { }, map: { sourceUnresolved: "No se pudo resolver el origen de datos de este mapa", + invalidCoordinatesExcluded: "Registros excluidos del mapa por coordenadas ausentes o no válidas: {{excluded}}", + styleFailedConfigured: "No se pudo cargar el mapa ({{reason}}). Los registros aparecen a continuación; revise la URL del estilo de mapa configurada en esta vista.", + styleFailedDefault: "No se pudo cargar el mapa ({{reason}}). Los registros aparecen a continuación; aquí no se pudieron cargar los mosaicos de mapa públicos predeterminados, así que pida a su administrador que configure un estilo de mapa para esta vista.", + styleFailedUnknownReason: "no se pudieron cargar el estilo o los mosaicos del mapa", + webgl2Unavailable: "No se pudo cargar el mapa (este navegador no admite WebGL2, que el mapa necesita). Los registros aparecen a continuación; active la aceleración por hardware o use un navegador con WebGL2 para ver el mapa.", + retry: "Reintentar el mapa", + listLabel: "Ubicaciones", + listEmpty: "No hay ubicaciones para mostrar.", }, timeline: { sourceUnresolved: "No se pudo resolver el origen de datos de esta línea de tiempo", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 16c2d6bef70..922604a33e9 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4797,6 +4797,14 @@ const fr = { }, map: { sourceUnresolved: "La source de données de cette carte n’a pas pu être résolue", + invalidCoordinatesExcluded: "Enregistrements exclus de la carte (coordonnées manquantes ou invalides) : {{excluded}}", + styleFailedConfigured: "Échec du chargement de la carte ({{reason}}). Les enregistrements sont listés ci-dessous ; vérifiez l’URL du style de carte défini sur cette vue.", + styleFailedDefault: "Échec du chargement de la carte ({{reason}}). Les enregistrements sont listés ci-dessous ; les tuiles de carte publiques par défaut n’ont pas pu être chargées ici, demandez à votre administrateur de définir un style de carte pour cette vue.", + styleFailedUnknownReason: "le style ou les tuiles de la carte n’ont pas pu être chargés", + webgl2Unavailable: "Échec du chargement de la carte (ce navigateur ne prend pas en charge WebGL2, nécessaire à la carte). Les enregistrements sont listés ci-dessous ; activez l’accélération matérielle ou utilisez un navigateur compatible WebGL2 pour afficher la carte.", + retry: "Réessayer la carte", + listLabel: "Lieux", + listEmpty: "Aucun lieu à afficher.", }, timeline: { sourceUnresolved: "La source de données de cette chronologie n’a pas pu être résolue", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index ae922186d1a..5acb1ec03ef 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4689,6 +4689,14 @@ const ja = { }, map: { sourceUnresolved: "この地図のデータソースを解決できませんでした", + invalidCoordinatesExcluded: "座標が未設定または無効なため地図から除外されたレコード:{{excluded}}", + styleFailedConfigured: "地図を読み込めませんでした({{reason}})。レコードは下に一覧表示しています。このビューに設定された地図スタイルの URL を確認してください。", + styleFailedDefault: "地図を読み込めませんでした({{reason}})。レコードは下に一覧表示しています。公開のデフォルト地図タイルをここでは読み込めないため、このビューの地図スタイルを設定するよう管理者に依頼してください。", + styleFailedUnknownReason: "地図スタイルまたはタイルを読み込めませんでした", + webgl2Unavailable: "地図を読み込めませんでした(このブラウザーは地図に必要な WebGL2 に対応していません)。レコードは下に一覧表示しています。ハードウェアアクセラレーションを有効にするか、WebGL2 対応のブラウザーで地図を表示してください。", + retry: "地図を再読み込み", + listLabel: "場所", + listEmpty: "表示する場所はありません。", }, timeline: { sourceUnresolved: "このタイムラインのデータソースを解決できませんでした", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 4d2e691ec58..d4acada2e0b 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4686,6 +4686,14 @@ const ko = { }, map: { sourceUnresolved: "이 지도의 데이터 소스를 확인할 수 없습니다", + invalidCoordinatesExcluded: "좌표가 없거나 잘못되어 지도에서 제외된 레코드: {{excluded}}", + styleFailedConfigured: "지도를 불러오지 못했습니다({{reason}}). 레코드는 아래에 나열되어 있습니다. 이 보기에 설정된 지도 스타일 URL을 확인하세요.", + styleFailedDefault: "지도를 불러오지 못했습니다({{reason}}). 레코드는 아래에 나열되어 있습니다. 여기에서는 공개 기본 지도 타일을 불러올 수 없으므로 관리자에게 이 보기의 지도 스타일을 설정해 달라고 요청하세요.", + styleFailedUnknownReason: "지도 스타일 또는 타일을 불러올 수 없습니다", + webgl2Unavailable: "지도를 불러오지 못했습니다(이 브라우저는 지도에 필요한 WebGL2를 지원하지 않습니다). 레코드는 아래에 나열되어 있습니다. 하드웨어 가속을 켜거나 WebGL2를 지원하는 브라우저에서 지도를 확인하세요.", + retry: "지도 다시 시도", + listLabel: "위치", + listEmpty: "표시할 위치가 없습니다.", }, timeline: { sourceUnresolved: "이 타임라인의 데이터 소스를 확인할 수 없습니다", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 6947902e67b..32d741de3b9 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4796,6 +4796,14 @@ const pt = { }, map: { sourceUnresolved: "Não foi possível resolver a fonte de dados deste mapa", + invalidCoordinatesExcluded: "Registros excluídos do mapa por coordenadas ausentes ou inválidas: {{excluded}}", + styleFailedConfigured: "Não foi possível carregar o mapa ({{reason}}). Os registros estão listados abaixo; verifique a URL do estilo de mapa definida nesta visualização.", + styleFailedDefault: "Não foi possível carregar o mapa ({{reason}}). Os registros estão listados abaixo; não foi possível carregar aqui os blocos de mapa públicos padrão, então peça ao seu administrador que defina um estilo de mapa para esta visualização.", + styleFailedUnknownReason: "não foi possível carregar o estilo ou os blocos do mapa", + webgl2Unavailable: "Não foi possível carregar o mapa (este navegador não oferece WebGL2, necessário para o mapa). Os registros estão listados abaixo; ative a aceleração de hardware ou use um navegador com WebGL2 para ver o mapa.", + retry: "Tentar carregar o mapa novamente", + listLabel: "Locais", + listEmpty: "Nenhum local para listar.", }, timeline: { sourceUnresolved: "Não foi possível resolver a fonte de dados desta linha do tempo", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index bd5316c6919..0eb0c9e04f3 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4923,6 +4923,14 @@ const ru = { }, map: { sourceUnresolved: "Не удалось определить источник данных этой карты", + invalidCoordinatesExcluded: "Записи, исключённые с карты из-за отсутствующих или неверных координат: {{excluded}}", + styleFailedConfigured: "Не удалось загрузить карту ({{reason}}). Записи перечислены ниже; проверьте URL стиля карты, заданный для этого представления.", + styleFailedDefault: "Не удалось загрузить карту ({{reason}}). Записи перечислены ниже; общедоступные тайлы карты по умолчанию здесь загрузить не удалось, попросите администратора задать стиль карты для этого представления.", + styleFailedUnknownReason: "не удалось загрузить стиль или тайлы карты", + webgl2Unavailable: "Не удалось загрузить карту (этот браузер не поддерживает WebGL2, необходимый для карты). Записи перечислены ниже; включите аппаратное ускорение или откройте карту в браузере с поддержкой WebGL2.", + retry: "Повторить загрузку карты", + listLabel: "Местоположения", + listEmpty: "Нет местоположений для отображения.", }, timeline: { sourceUnresolved: "Не удалось определить источник данных этой временной шкалы", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index e7253201c8d..83c7690e8c5 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4744,6 +4744,14 @@ const zh = { }, map: { sourceUnresolved: '无法解析此地图的数据源', + invalidCoordinatesExcluded: '因坐标缺失或无效而未显示在地图上的记录:{{excluded}}', + styleFailedConfigured: '地图加载失败({{reason}})。记录已列在下方;请检查此视图设置的地图样式 URL。', + styleFailedDefault: '地图加载失败({{reason}})。记录已列在下方;此处无法加载公共默认地图瓦片,请联系管理员为此视图设置地图样式。', + styleFailedUnknownReason: '无法加载地图样式或瓦片', + webgl2Unavailable: '地图加载失败(此浏览器不支持地图所需的 WebGL2)。记录已列在下方;请开启硬件加速,或使用支持 WebGL2 的浏览器查看地图。', + retry: '重试加载地图', + listLabel: '位置', + listEmpty: '没有可列出的位置。', }, timeline: { sourceUnresolved: '无法解析此时间线的数据源', diff --git a/ui/packages/plugin-map/src/ObjectMap.noticesI18n-25889.test.tsx b/ui/packages/plugin-map/src/ObjectMap.noticesI18n-25889.test.tsx new file mode 100644 index 00000000000..27635bb745f --- /dev/null +++ b/ui/packages/plugin-map/src/ObjectMap.noticesI18n-25889.test.tsx @@ -0,0 +1,73 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#25889 — the map's notices read the bundle: a zh-CN console shows + * them in Chinese, not as the English literals they used to be. + * + * Its own FILE because mounting `I18nProvider` registers its instance as + * react-i18next's module-global default for the rest of the file; the + * provider-less English path is pinned in + * `ObjectMap.styleFailureList-25889.test.tsx`, which never mounts one. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { ObjectMapSchema } from '@object-ui/types'; +import { I18nProvider } from '@object-ui/react'; +import { ObjectMap } from './ObjectMap'; +import './__tests__/webgl2Available'; + +type Slot = { children?: React.ReactNode }; + +vi.mock('react-map-gl/maplibre', () => ({ + default: (props: Slot & { onError?: (e: { error?: Error }) => void }) => ( +
+
+ ), + NavigationControl: () =>
, + Marker: ({ children }: Slot) =>
{children}
, + Popup: ({ children }: Slot) =>
{children}
, +})); + +const records = [ + { id: 't1', title: 'Install rooftop unit', location: { lat: 47.6062, lng: -122.3321 } }, + { id: 't2', title: 'No coordinates' }, +]; + +const schema: ObjectMapSchema = { type: 'object-map', map: { locationField: 'location', titleField: 'title' } }; + +describe('ObjectMap notices under zh-CN (objectstack#25889)', () => { + it('the excluded-records notice and the style-failure alert are in Chinese', async () => { + render( + + + , + ); + await screen.findByLabelText('Map'); + await waitFor(() => + expect(screen.getByText('因坐标缺失或无效而未显示在地图上的记录:1')).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByTestId('simulate-style-error')); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent( + '地图加载失败(Failed to fetch)。记录已列在下方;此处无法加载公共默认地图瓦片,请联系管理员为此视图设置地图样式。', + ); + expect(screen.getByTestId('map-style-retry')).toHaveTextContent('重试加载地图'); + expect(screen.getByRole('list', { name: '位置' })).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/plugin-map/src/ObjectMap.styleFailureList-25889.test.tsx b/ui/packages/plugin-map/src/ObjectMap.styleFailureList-25889.test.tsx new file mode 100644 index 00000000000..8fd25ae074f --- /dev/null +++ b/ui/packages/plugin-map/src/ObjectMap.styleFailureList-25889.test.tsx @@ -0,0 +1,219 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#25889 — a style or tile load failure swaps the map for the record + * list, with a notice that says what happened and who can fix it. + * + * A deployment whose network cannot reach the tile host (an intranet, a + * proxy, a policy that forbids third-party hosts) used to keep a grey canvas + * with the markers floating over nothing, under an English alert that sent the + * reader to a "search box" listing that did not exist and to a "custom map + * style for production use" setting that does not exist either. + * + * The map here is a stub that stands in for react-map-gl: it mounts like the + * real one (an effect whose cleanup is the real one's `map.remove()`), and it + * fires `onError` the way MapLibre's `error` event reaches react-map-gl. The + * cleanup counter is what makes the unmount path readable: swapping the map out + * must remove it exactly once, and unmounting the view afterwards must not + * reach into a map that is already gone. + */ + +import React, { useEffect } from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ObjectMapSchema } from '@object-ui/types'; +import { ObjectMap } from './ObjectMap'; +import './__tests__/webgl2Available'; + +type Slot = { children?: React.ReactNode }; + +const lifecycle = { mounts: 0, removes: 0 }; + +vi.mock('react-map-gl/maplibre', () => ({ + default: function StubMap( + props: Slot & { mapStyle?: string; onError?: (e: { error?: Error }) => void }, + ) { + useEffect(() => { + lifecycle.mounts += 1; + return () => { + lifecycle.removes += 1; + }; + }, []); + return ( +
+
+ ); + }, + NavigationControl: () =>
, + Marker: ({ children }: Slot) =>
{children}
, + Popup: ({ children }: Slot) =>
{children}
, +})); + +/** Stands in for the page's error boundary: what it catches is the crash card. */ +class CrashCard extends React.Component< + { onCatch: (error: Error) => void; children: React.ReactNode }, + { crashed: boolean } +> { + state = { crashed: false }; + static getDerivedStateFromError() { + return { crashed: true }; + } + componentDidCatch(error: Error) { + this.props.onCatch(error); + } + render() { + return this.state.crashed ?
: this.props.children; + } +} + +const records = [ + { id: 't1', title: 'Install rooftop unit', notes: 'North wing', location: { lat: 47.6062, lng: -122.3321 } }, + { id: 't2', title: 'Replace boiler', notes: 'Basement', location: { lat: 45.5152, lng: -122.6784 } }, +]; + +const MAP = { locationField: 'location', titleField: 'title', descriptionField: 'notes' }; +const schema: ObjectMapSchema = { type: 'object-map', map: MAP }; + +let caught: Error[]; + +beforeEach(() => { + lifecycle.mounts = 0; + lifecycle.removes = 0; + caught = []; +}); + +function mount(props: Partial> = {}) { + return render( + caught.push(error)}> + + , + ); +} + +async function failStyle(testId = 'simulate-style-error') { + await screen.findByLabelText('Map'); + fireEvent.click(screen.getByTestId(testId)); + return screen.findByTestId('map-style-fallback'); +} + +describe('ObjectMap — a style or tile failure degrades to the record list (objectstack#25889)', () => { + it('replaces the map with the record list, removing the map exactly once', async () => { + mount(); + const list = await failStyle(); + + expect(screen.queryByLabelText('Map')).toBeNull(); + expect(screen.queryAllByTestId('map-marker')).toHaveLength(0); + expect(lifecycle).toEqual({ mounts: 1, removes: 1 }); + expect(list).toHaveTextContent('Install rooftop unit'); + expect(screen.getByRole('button', { name: /Replace boiler/ })).toHaveTextContent('Basement'); + // Not the WebGL2 case: that one has its own notice and no retry. + expect(screen.queryByTestId('map-webgl2-fallback')).toBeNull(); + expect(caught).toEqual([]); + }); + + it('the notice keeps MapLibre’s reason and sends the default-style reader to an administrator', async () => { + mount(); + await failStyle(); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent( + 'Map failed to load (AJAXError: Failed to fetch (0): https://demotiles.maplibre.org/style.json).', + ); + expect(alert).toHaveTextContent('The records are listed below'); + expect(alert).toHaveTextContent('ask your administrator to set a map style for this view'); + // The two claims the old alert made and nothing delivered. + expect(alert.textContent).not.toMatch(/search box/); + expect(alert.textContent).not.toMatch(/production/); + }); + + it('a view with its own style is told to check that style', async () => { + mount({ schema: { ...schema, map: { ...MAP, style: 'https://tiles.example.com/style.json' } } }); + await failStyle(); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('(AJAXError: Failed to fetch (0): https://tiles.example.com/style.json)'); + expect(alert).toHaveTextContent('check the map style URL set on this view'); + expect(alert.textContent).not.toMatch(/administrator/); + }); + + it('an error event with no message still names a reason', async () => { + mount(); + await failStyle('simulate-bare-error'); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Map failed to load (the map style or tiles could not be loaded).', + ); + }); + + it('keeps the listed records searchable', async () => { + mount(); + await failStyle(); + + fireEvent.change(screen.getByPlaceholderText('Search locations…'), { target: { value: 'boiler' } }); + + expect(screen.getByRole('button', { name: /Replace boiler/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Install rooftop unit/ })).toBeNull(); + }); + + it('a listed record takes the marker click path', async () => { + const onMarkerClick = vi.fn(); + const onRowClick = vi.fn(); + mount({ onMarkerClick, onRowClick }); + await failStyle(); + + fireEvent.click(screen.getByRole('button', { name: /Install rooftop unit/ })); + + expect(onMarkerClick).toHaveBeenCalledWith(records[0]); + expect(onRowClick).toHaveBeenCalledWith(records[0], undefined); + }); + + it('“Try the map again” mounts a fresh map with its markers', async () => { + mount(); + await failStyle(); + + fireEvent.click(screen.getByTestId('map-style-retry')); + + expect(await screen.findByLabelText('Map')).toBeInTheDocument(); + expect(screen.getAllByTestId('map-marker')).toHaveLength(2); + expect(screen.queryByTestId('map-style-fallback')).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(lifecycle).toEqual({ mounts: 2, removes: 1 }); + }); + + it('unmounts cleanly after degrading: the removed map is not removed again', async () => { + const { unmount } = mount(); + await failStyle(); + + expect(() => unmount()).not.toThrow(); + expect(lifecycle).toEqual({ mounts: 1, removes: 1 }); + expect(caught).toEqual([]); + }); + + it('the excluded-records notice reads the bundle’s English default', async () => { + mount({ data: [...records, { id: 't3', title: 'No coordinates' }] }); + await screen.findByLabelText('Map'); + + await waitFor(() => + expect( + screen.getByText('Records excluded from the map for missing or invalid coordinates: 1'), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/ui/packages/plugin-map/src/ObjectMap.styleFallback.test.tsx b/ui/packages/plugin-map/src/ObjectMap.styleFallback.test.tsx index 479c9b59cc3..d1251194119 100644 --- a/ui/packages/plugin-map/src/ObjectMap.styleFallback.test.tsx +++ b/ui/packages/plugin-map/src/ObjectMap.styleFallback.test.tsx @@ -3,8 +3,9 @@ * Copyright (c) 2024-present ObjectStack Inc. * * Regression: a failed MapLibre style/tile load used to render a blank map - * with no indication anything went wrong. `onError` now surfaces a banner, - * and a schema-configured `style` overrides the public demo default. + * with no indication anything went wrong. `onError` now surfaces an alert over + * the record list that replaces the map (objectstack#25889), and a + * schema-configured `style` overrides the public demo default. */ import React from 'react'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; diff --git a/ui/packages/plugin-map/src/ObjectMap.tsx b/ui/packages/plugin-map/src/ObjectMap.tsx index 1711052d510..c1b8fedeb40 100644 --- a/ui/packages/plugin-map/src/ObjectMap.tsx +++ b/ui/packages/plugin-map/src/ObjectMap.tsx @@ -30,6 +30,7 @@ import { useSettledSchema, useFilterScope, useResolvedFilter, + useObjectTranslation, } from '@object-ui/react'; import { NavigationOverlay, cn, useIsMobile } from '@object-ui/components'; import { usePermissions } from '@object-ui/permissions'; @@ -61,8 +62,8 @@ import { /** * Public MapLibre demo style — free, unauthenticated, but not intended for * production traffic (rate-limited, no uptime guarantee). Used only when no - * `style` is configured; a load failure surfaces the banner below rather - * than failing silently. + * `style` is configured; a load failure swaps the map for the record list + * below rather than leaving a grey canvas. */ const DEFAULT_MAP_STYLE = 'https://demotiles.maplibre.org/style.json'; @@ -679,17 +680,25 @@ export const ObjectMap: React.FC = ({ const [userLocation, setUserLocation] = useState<{ lng: number; lat: number } | null>(null); const [geoError, setGeoError] = useState(null); const [geoBusy, setGeoBusy] = useState(false); - // Style/tile load failures (bad network, unreachable style server) don't - // throw from react-map-gl — they emit an `error` event. Without this, a - // failed load renders a blank map with no indication anything went wrong. + const { t } = useObjectTranslation(); + // Style/tile load failures (bad network, unreachable style server, a tile + // host the deployment's network blocks) don't throw from react-map-gl — they + // emit an `error` event. The map left behind is a grey canvas: the markers + // float over nothing, so the view swaps it for the same record list a + // browser without WebGL2 gets (objectstack#25889). Holds MapLibre's own + // reason, `''` when it gave none; `null` while the map is healthy. Kept + // across refetches — the same style would fail again — until the user asks + // for the map back. const [mapStyleError, setMapStyleError] = useState(null); const handleMapError = useCallback((e: { error?: Error & { status?: number } }) => { - setMapStyleError(e?.error?.message || 'Failed to load the map style/tiles.'); + setMapStyleError(e?.error?.message ?? ''); }, []); + const retryMap = useCallback(() => setMapStyleError(null), []); // Asked once per mount, before `MapGL` is ever mounted: a browser without - // WebGL2 gets the record list instead of a map (see `canStartMap`). Not a - // style/tile failure — that one keeps the map and its markers, above. + // WebGL2 gets the record list instead of a map (see `canStartMap`). There is + // no retry for that one — the browser will not grow WebGL2 by asking again. const [mapCanStart] = useState(canStartMap); + const showRecordList = !mapCanStart || mapStyleError !== null; const requestUserLocation = useCallback(() => { if (typeof navigator === 'undefined' || !navigator.geolocation) { setGeoError('Geolocation is not available in this browser.'); @@ -1329,10 +1338,10 @@ export const ObjectMap: React.FC = ({ * `map.` because the declared block is the authoring surface — the flat * top-level spelling this component also reads is the internal transport form * ObjectView / ListView produce (see `FlatMapConfigKeys`), which no author - * writes. ⛔ Not translated: `@object-ui/plugin-map` carries no i18n - * dependency and every user-facing string in this file is a literal, the same - * shape as the "Calendar/Gantt configuration required" refusals in the - * sibling plugins. + * writes. ⛔ Not translated: it is an authoring diagnostic whose words are + * metadata keys, the same shape as the "Calendar/Gantt configuration + * required" refusals in the sibling plugins. The notices a VIEWER reads — + * the excluded-records count, the map-failed alerts — go through the bundle. */ if (!hasCoordinateBinding(mapConfig)) { return ( @@ -1372,11 +1381,38 @@ export const ObjectMap: React.FC = ({ ); } + // Why the record list stands where the map should be. No deployment-wide map + // style setting exists, so neither style remedy promises one: a configured + // style is this view's own to check, and the public default is for an + // administrator to replace with a style set on this view. + const styleFailureReason = + mapStyleError || + t('element.map.styleFailedUnknownReason', { defaultValue: 'the map style or tiles could not be loaded' }); + const recordListNotice = !mapCanStart + ? t('element.map.webgl2Unavailable', { + defaultValue: + 'Map failed to load (this browser does not provide WebGL2, which the map needs). The records are listed below; turn on hardware acceleration or use a browser with WebGL2 to see the map.', + }) + : mapConfig.style + ? t('element.map.styleFailedConfigured', { + reason: styleFailureReason, + defaultValue: + 'Map failed to load ({{reason}}). The records are listed below; check the map style URL set on this view.', + }) + : t('element.map.styleFailedDefault', { + reason: styleFailureReason, + defaultValue: + 'Map failed to load ({{reason}}). The records are listed below; the public default map tiles could not be loaded here, so ask your administrator to set a map style for this view.', + }); + return (
{invalidCount > 0 && (
- {`${invalidCount} record${invalidCount !== 1 ? 's' : ''} with missing or invalid coordinates excluded from the map.`} + {t('element.map.invalidCoordinatesExcluded', { + excluded: invalidCount, + defaultValue: 'Records excluded from the map for missing or invalid coordinates: {{excluded}}', + })}
)} {markers.length > 0 && ( @@ -1391,22 +1427,41 @@ export const ObjectMap: React.FC = ({
)}
- {!mapCanStart ? ( - // No WebGL2: `MapGL` is never mounted (see `canStartMap`), so the - // records the markers would draw are listed here instead — the same + {showRecordList ? ( + // No map to draw on — no WebGL2 (`MapGL` is never mounted, see + // `canStartMap`), or a style/tile load failed (`MapGL` is unmounted, + // which runs its `map.remove()` on a map that did start). The + // records the markers would draw are listed here instead: the same // `filteredMarkers` the search box above narrows, each row taking // the marker's own click path. -
+
- Map failed to load (this browser does not provide WebGL2, which the map needs). The records are - listed below; turn on hardware acceleration or use a browser with WebGL2 to see the map. + {recordListNotice} + {mapCanStart && ( + + )}
-
    +
      {filteredMarkers.length === 0 && ( -
    • No locations to list.
    • +
    • + {t('element.map.listEmpty', { defaultValue: 'No locations to list.' })} +
    • )} {filteredMarkers.map((marker) => (
    • @@ -1440,15 +1495,6 @@ export const ObjectMap: React.FC = ({ onZoom={(e) => setCurrentZoom(Math.round(e.viewState.zoom))} onError={handleMapError} > - {mapStyleError && ( -
      - Map failed to load ({mapStyleError}). Markers below are still listed in the search box;{' '} - {mapConfig.style ? 'check the configured map style URL.' : 'configure a custom map style for production use.'} -
      - )} {/* Geolocation button — explicit user-initiated location request */} diff --git a/ui/packages/plugin-map/src/ObjectMap.webgl2Controls-11819.test.tsx b/ui/packages/plugin-map/src/ObjectMap.webgl2Controls-11819.test.tsx index dfdd46180ba..7cba1b61472 100644 --- a/ui/packages/plugin-map/src/ObjectMap.webgl2Controls-11819.test.tsx +++ b/ui/packages/plugin-map/src/ObjectMap.webgl2Controls-11819.test.tsx @@ -12,8 +12,10 @@ * * - A browser that answers the probe mounts the map, markers and all, and * shows no fallback list. - * - A style or tile failure is a different, non-fatal case: the map keeps - * running, so it keeps its markers and gains the alert over them. + * - A style or tile failure is a different case: the map did start, so it + * is removed and the record list takes its place with a retry, under its + * own test id (objectstack#25889; the full battery is + * `ObjectMap.styleFailureList-25889.test.tsx`). * - The probe is asked once per mount and hands its context straight back. * * The map is the stub the sibling tests use; `./__tests__/webgl2Available` @@ -62,16 +64,18 @@ describe('ObjectMap with WebGL2 — what the probe leaves alone (objectui#11819) expect(screen.queryByRole('alert')).toBeNull(); }); - it('a style or tile failure keeps the map and its markers, with the alert over them', async () => { + it('a style or tile failure swaps the map for the record list, not the WebGL2 one', async () => { render(); await screen.findByLabelText('Map'); fireEvent.click(screen.getByTestId('simulate-style-error')); expect(await screen.findByRole('alert')).toHaveTextContent('Map failed to load (Failed to fetch)'); - expect(screen.getByLabelText('Map')).toBeInTheDocument(); - expect(screen.getAllByTestId('map-marker')).toHaveLength(2); + expect(screen.queryByLabelText('Map')).toBeNull(); + expect(screen.queryAllByTestId('map-marker')).toHaveLength(0); + expect(screen.getByTestId('map-style-fallback')).toHaveTextContent('Replace boiler'); expect(screen.queryByTestId('map-webgl2-fallback')).toBeNull(); + expect(screen.getByTestId('map-style-retry')).toBeInTheDocument(); }); it('asks for WebGL2 once per mount and releases the probe context', async () => { diff --git a/ui/packages/plugin-map/src/ObjectMap.webgl2Fallback-11819.test.tsx b/ui/packages/plugin-map/src/ObjectMap.webgl2Fallback-11819.test.tsx index ba0585f763a..1039dc5163b 100644 --- a/ui/packages/plugin-map/src/ObjectMap.webgl2Fallback-11819.test.tsx +++ b/ui/packages/plugin-map/src/ObjectMap.webgl2Fallback-11819.test.tsx @@ -110,6 +110,8 @@ describe('ObjectMap without WebGL2 (objectui#11819)', () => { expect(alert).toHaveTextContent('WebGL2'); expect(screen.getByRole('button', { name: /Install rooftop unit/ })).toHaveTextContent('North wing'); expect(screen.getByRole('button', { name: /Replace boiler/ })).toHaveTextContent('Basement'); + // No retry here: the browser will not grow WebGL2 by asking again. + expect(screen.queryByTestId('map-style-retry')).toBeNull(); }); it('keeps the records searchable: the search box narrows the list', async () => { From 5f0bd606a768a72852616e6a00fd55129eeb6bae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:58:04 +0000 Subject: [PATCH 2/4] test(i18n): the map's record-list label leaves the aria-label literal backlog Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01HUu4jGnAzZqaB5qTG2fsX4 --- .../i18n/src/__tests__/ariaLabelLiteralCensus-12145.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/i18n/src/__tests__/ariaLabelLiteralCensus-12145.test.ts b/ui/packages/i18n/src/__tests__/ariaLabelLiteralCensus-12145.test.ts index dc1a925e719..e9942242f24 100644 --- a/ui/packages/i18n/src/__tests__/ariaLabelLiteralCensus-12145.test.ts +++ b/ui/packages/i18n/src/__tests__/ariaLabelLiteralCensus-12145.test.ts @@ -282,7 +282,7 @@ const BACKLOG: Declared[] = ( ['packages/plugin-list/src/ListView.tsx', ['Bulk actions']], [ 'packages/plugin-map/src/ObjectMap.tsx', - ['Locations', 'Show my location', 'Your location', 'Location details', 'Close details'], + ['Show my location', 'Your location', 'Location details', 'Close details'], ], ] as Array<[string, string[]]> ).map(([file, names]) => ({ file, names, verdict: 'backlog' as const, reason: BACKLOG_REASON })); From f4d90cad07453c1dc96c57247356fc577b3795ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:16:16 +0000 Subject: [PATCH 3/4] chore(sdui): re-record the manifest record for the plugin-map source change Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01HUu4jGnAzZqaB5qTG2fsX4 --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1555759c7e3..86207e4e9a6 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "ad9e0875b83f339e03c528fc3be774592615dfdc5ee5fe9becc4376ddb5f0d52" + "registrySourcesSha256": "9cf3c26e17780d508ecd40e2923c32263b2a3c1111c2974c6ae1abfcb62c47ad" } } From 79dcecfdd5733b4954e829df82b33bc0f05cdecf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:47:39 +0000 Subject: [PATCH 4/4] chore(sdui): re-record the manifest record after merging main Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01HUu4jGnAzZqaB5qTG2fsX4 --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..1ffac470455 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "28bb61d046e46c8286a75a7b887d31bfc0d23220c8fd51d2fdff1d99134c932f" } }