diff --git a/.changeset/26163-map-style-runtime-fallback.md b/.changeset/26163-map-style-runtime-fallback.md new file mode 100644 index 00000000000..2bc284afa0a --- /dev/null +++ b/.changeset/26163-map-style-runtime-fallback.md @@ -0,0 +1,24 @@ +--- +'@object-ui/plugin-map': patch +'@object-ui/react': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(plugin-map): a map view with no style of its own draws the deployment's default map style before the public demo style + +Clause-②: no +Metadata-protocol: none + +An operator who sets `OS_CONSOLE_MAP_STYLE_URL` now gets that style on every map view that names none. A map draws the first style it finds, in this order: + +1. the view's own style: `mapStyle`, then `map.style`; +2. the deployment's default style, the runtime-config `mapStyleUrl`; +3. the public MapLibre demo style. + +- **`@object-ui/react`:** `HostMapStyleProvider` and `useHostMapStyle()` let a host hand the deployment's default map style to the map renderer. Outside a provider, the hook answers `undefined` and the map keeps the demo style. +- **`@object-ui/app-shell`:** the runtime config reads the top-level `mapStyleUrl` into `AppShellRuntimeConfig.mapStyleUrl`, re-read from each payload, and `ConsoleShell` supplies it through `HostMapStyleProvider`. +- **`@object-ui/plugin-map`:** `ObjectMap` reads the host's style after the view's own. When the deployment's style fails to load, the notice above the record list says the deployment's default map style could not be loaded and asks the reader to have an administrator check it. It no longer blames the public default tiles, which that map never drew. +- **`@object-ui/i18n`:** one new key, `element.map.styleFailedDeployment`, in all ten packs. + +What to do: nothing. A deployment that sets no `OS_CONSOLE_MAP_STYLE_URL` draws the demo style as before, and a view's own `mapStyle` still wins. diff --git a/ui/content/docs/plugins/plugin-map.mdx b/ui/content/docs/plugins/plugin-map.mdx index 3e03e499592..15c4a48c432 100644 --- a/ui/content/docs/plugins/plugin-map.mdx +++ b/ui/content/docs/plugins/plugin-map.mdx @@ -158,7 +158,7 @@ to `onMarkerClick` is not `===` the authored object. descriptionField?: string, // Field for marker description zoom?: number, // Zoom level (1-20) — opts out of the auto-fit center?: [number, number], // Center coordinates [lat, lng] — opts out of the auto-fit - style?: string // MapLibre style URL/spec (overrides the demo default) + style?: string // MapLibre style URL/spec (overrides the deployment and demo defaults) } ``` @@ -171,6 +171,23 @@ warns `[ObjectMap] Invalid map configuration`, naming the same key. A typo that leaves the block with no coordinate binding, as `latitudeFieId` does, draws the refusal described under "An unconfigured map refuses" rather than a map. +## Which style draws + +A map draws the first style it finds, in this order: + +1. the view's own style: the node-level `mapStyle`, then `map.style`; +2. the deployment's default style, which the host hands down through + `HostMapStyleProvider` from `@object-ui/react`. The Console supplies the + operator's `OS_CONSOLE_MAP_STYLE_URL`, served as `mapStyleUrl` on + `GET /api/v1/runtime/config`; +3. the public MapLibre demo style (`https://demotiles.maplibre.org/style.json`), + which is rate-limited and not meant for production traffic. + +A host that provides no style, such as an embedded renderer or a Console whose +operator set none, keeps the demo style. When the style that drew fails to load, +the map is replaced by its record list, and the notice names that style: the +view's own, the deployment's or the public default. + ## Configuration ### An unconfigured map refuses diff --git a/ui/packages/app-shell/src/console/ConsoleShell.tsx b/ui/packages/app-shell/src/console/ConsoleShell.tsx index 50c25b66c32..99e3961489c 100644 --- a/ui/packages/app-shell/src/console/ConsoleShell.tsx +++ b/ui/packages/app-shell/src/console/ConsoleShell.tsx @@ -14,7 +14,7 @@ import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { AuthGuard, useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; -import { SchemaRendererProvider, ActionProvider, NotificationProvider } from '@object-ui/react'; +import { SchemaRendererProvider, ActionProvider, NotificationProvider, HostMapStyleProvider } from '@object-ui/react'; import { NotificationAlerts, NotificationSnackbar } from '@object-ui/components'; import { presentNotificationToast } from '../chrome/notificationToast.js'; import { useActionModal } from '../hooks/useActionModal.js'; @@ -54,6 +54,7 @@ import { ConsoleElementApiOpen } from './ConsoleElementApiOpen.js'; import { ImpersonationBanner } from '../layout/ImpersonationBanner.js'; import { ReadRateBanner } from '../layout/ReadRateBanner.js'; import { StorageUsageBanner } from '../layout/StorageUsageBanner.js'; +import { getRuntimeConfig } from '../runtime-config.js'; // The console's every pre-React / pre-auth gate (Suspense fallback, adapter // not ready, org/auth loading) renders this. It used to be a bare, unbranded @@ -162,7 +163,15 @@ export function ConsoleShell({ children }: { children: ReactNode }) { console route and several arrive through `ComponentRegistry`, where no prop could be passed. */ - {children} + {/* objectstack#26163 — the deployment's default map style (the + operator's `OS_CONSOLE_MAP_STYLE_URL`, served as runtime-config + `mapStyleUrl`), for a map view that declares no style of its own. + The map arrives through `ComponentRegistry`, lazily, so a context is + the only way to reach it; absent, the map keeps the demo style. + `initRuntimeConfig()` settles before the console's first render. */} + + {children} + ); } diff --git a/ui/packages/app-shell/src/console/__tests__/ConsoleShell.mapStyle-26163.test.tsx b/ui/packages/app-shell/src/console/__tests__/ConsoleShell.mapStyle-26163.test.tsx new file mode 100644 index 00000000000..d19068a3ded --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/ConsoleShell.mapStyle-26163.test.tsx @@ -0,0 +1,68 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * `ConsoleShell` SUPPLIES the deployment's default map style (objectstack#26163). + * + * The runtime serves the operator's `OS_CONSOLE_MAP_STYLE_URL` as the top-level + * `mapStyleUrl` of `GET /api/v1/runtime/config` (objectstack#26001), and the map + * renderer reads it through `useHostMapStyle()` from `@object-ui/react`. The map + * is a lazily registered plugin, so nothing but this context reaches it; this + * file pins that the console actually mounts the provider with the served value, + * which no test of the renderer or of the config reader can see. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { useHostMapStyle } from '@object-ui/react'; +import { ConsoleShell } from '../ConsoleShell'; +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config'; + +// Same two stubs the sibling ConsoleShell tests use: sonner is a global toast +// surface, and the ADR-0069 remediation gate needs an AuthProvider above it. +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn(), + dismiss: vi.fn(), custom: vi.fn(), loading: vi.fn(), promise: vi.fn(), + }), + Toaster: () => null, +})); +vi.mock('../RemediationOverlay', () => ({ RemediationOverlay: () => null })); + +const STYLE = 'https://tiles.intranet.example/styles/basic/style.json'; + +/** A stand-in for the map renderer below the shell. */ +function MapStub() { + const style = useHostMapStyle(); + return
{style ?? 'none'}
; +} + +async function boot(payload: Record) { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => payload })) as any); + await initRuntimeConfig(); + render( + + + + + , + ); +} + +afterEach(() => { + resetRuntimeConfigForTesting(); + vi.unstubAllGlobals(); +}); + +describe('ConsoleShell — the console supplies the deployment map style (objectstack#26163)', () => { + it('hands the served mapStyleUrl to every renderer below it', async () => { + await boot({ mapStyleUrl: STYLE }); + await waitFor(() => expect(screen.getByTestId('host-map-style').textContent).toBe(STYLE)); + }); + + it('hands nothing when the runtime serves no mapStyleUrl', async () => { + await boot({ singleEnvironment: true }); + await waitFor(() => expect(screen.getByTestId('host-map-style').textContent).toBe('none')); + }); +}); diff --git a/ui/packages/app-shell/src/runtime-config.test.ts b/ui/packages/app-shell/src/runtime-config.test.ts index e6d09b2e606..046f08dacc9 100644 --- a/ui/packages/app-shell/src/runtime-config.test.ts +++ b/ui/packages/app-shell/src/runtime-config.test.ts @@ -280,6 +280,43 @@ describe('runtime-config hideSingleAppSwitcher (objectui#12913)', () => { }); }); +describe('runtime-config mapStyleUrl (objectstack#26163)', () => { + const STYLE = 'https://tiles.intranet.example/styles/basic/style.json'; + + it('is absent before init', () => { + resetRuntimeConfigForTesting(); + expect(getRuntimeConfig().mapStyleUrl).toBeUndefined(); + }); + + it('takes the top-level mapStyleUrl the runtime serves', async () => { + mockPayload({ mapStyleUrl: STYLE }); + await initRuntimeConfig(); + expect(getRuntimeConfig().mapStyleUrl).toBe(STYLE); + }); + + it.each([ + ['absent (the operator set no OS_CONSOLE_MAP_STYLE_URL)', { singleEnvironment: true }], + ['empty', { mapStyleUrl: '' }], + ['whitespace', { mapStyleUrl: ' ' }], + ['not a string', { mapStyleUrl: 42 }], + ['nested under branding, where it is not read', { branding: { mapStyleUrl: STYLE } }], + ])('is absent when the key is %s', async (_label, payload) => { + mockPayload(payload); + await initRuntimeConfig(); + expect(getRuntimeConfig().mapStyleUrl).toBeUndefined(); + }); + + it('withdraws it when a later fetch no longer serves it', async () => { + mockPayload({ mapStyleUrl: STYLE }); + await initRuntimeConfig(); + expect(getRuntimeConfig().mapStyleUrl).toBe(STYLE); + + mockPayload({ singleEnvironment: false }); + await initRuntimeConfig(); + expect(getRuntimeConfig().mapStyleUrl).toBeUndefined(); + }); +}); + describe('runtime-config platform stage', () => { it('defaults to preview before init (badge shows out of the box)', () => { resetRuntimeConfigForTesting(); diff --git a/ui/packages/app-shell/src/runtime-config.ts b/ui/packages/app-shell/src/runtime-config.ts index 7744ff93f7a..41a5addeba9 100644 --- a/ui/packages/app-shell/src/runtime-config.ts +++ b/ui/packages/app-shell/src/runtime-config.ts @@ -319,6 +319,20 @@ export interface AppShellRuntimeConfig { * `singleEnvironment`: a later payload without it withdraws it. */ hideSingleAppSwitcher?: boolean; + /** + * The deployment's default map style (objectstack#26163): the operator's + * `OS_CONSOLE_MAP_STYLE_URL`, served by `RuntimeConfigPlugin` as the + * top-level `mapStyleUrl` (objectstack#26001) and absent when the operator + * set none. A map view with no style of its own draws it before the public + * demo style; `ConsoleShell` hands it to the map renderer through + * `HostMapStyleProvider` from `@object-ui/react`. + * + * Re-derived from each payload in full, like `hideSingleAppSwitcher`: a + * later payload without the key withdraws it. Only a non-empty string is + * taken; the producer already refuses to boot on a value that is not an + * absolute `https:` URL, so nothing is re-validated here. + */ + mapStyleUrl?: string; } const defaults: AppShellRuntimeConfig = { @@ -441,6 +455,12 @@ export async function initRuntimeConfig(baseUrl: string = ''): Promise { // objectui#12913 — a host's request, so only a real `true` turns it on; // `'true'`, `1` and an absent key all keep the switcher. hideSingleAppSwitcher: body.hideSingleAppSwitcher === true, + // objectstack#26163 — the operator's map style, or none. Absent, empty + // and non-string all withdraw it, so the map falls back to the demo style. + mapStyleUrl: + typeof body.mapStyleUrl === 'string' && body.mapStyleUrl.trim() + ? body.mapStyleUrl.trim() + : undefined, defaultOrgId: body.defaultOrgId ?? current.defaultOrgId ?? null, defaultEnvironmentId: body.defaultEnvironmentId ?? current.defaultEnvironmentId ?? null, features: body.features diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index bfd1640c2d0..a349d9c2d03 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -5101,6 +5101,7 @@ const ar = { invalidCoordinatesExcluded: "السجلات المستبعدة من الخريطة بسبب إحداثيات مفقودة أو غير صالحة: {{excluded}}", styleFailedConfigured: "تعذّر تحميل الخريطة ({{reason}}). السجلات مدرجة أدناه؛ تحقّق من عنوان URL لنمط الخريطة المحدد في طريقة العرض هذه.", styleFailedDefault: "تعذّر تحميل الخريطة ({{reason}}). السجلات مدرجة أدناه؛ تعذّر تحميل مربعات الخريطة العامة الافتراضية هنا، لذا اطلب من المسؤول تعيين نمط خريطة لطريقة العرض هذه.", + styleFailedDeployment: "تعذّر تحميل الخريطة ({{reason}}). السجلات مدرجة أدناه؛ تعذّر هنا تحميل نمط الخريطة الافتراضي المعيّن لهذا النشر، لذا اطلب من المسؤول التحقق منه.", styleFailedUnknownReason: "تعذّر تحميل نمط الخريطة أو مربعاتها", webgl2Unavailable: "تعذّر تحميل الخريطة (هذا المتصفح لا يوفّر WebGL2 الذي تحتاجه الخريطة). السجلات مدرجة أدناه؛ فعّل تسريع الأجهزة أو استخدم متصفحًا يدعم WebGL2 لعرض الخريطة.", retry: "إعادة محاولة تحميل الخريطة", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index c8fc3f70e11..c2102cb2823 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4639,6 +4639,7 @@ const de = { 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.", + styleFailedDeployment: "Die Karte konnte nicht geladen werden ({{reason}}). Die Datensätze sind unten aufgelistet; der für diese Bereitstellung festgelegte Standard-Kartenstil konnte hier nicht geladen werden. Bitten Sie Ihren Administrator, ihn zu überprüfen.", 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", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index cb329582ce5..58f3ce379f1 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5437,13 +5437,15 @@ 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 + // objectstack#25889 — the map view's own notices. The three style + // failures (the view's own style, the deployment's default style — + // objectstack#26163 — and the public default) 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.', + styleFailedDeployment: 'Map failed to load ({{reason}}). The records are listed below; the default map style set for this deployment could not be loaded here, so ask your administrator to check it.', 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', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 61fd22901da..4ff0d657dac 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4754,6 +4754,7 @@ const es = { 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.", + styleFailedDeployment: "No se pudo cargar el mapa ({{reason}}). Los registros aparecen a continuación; aquí no se pudo cargar el estilo de mapa predeterminado configurado para esta implementación, así que pida a su administrador que lo revise.", 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", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 4e3e96dc9ba..2e9d442f5ea 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4750,6 +4750,7 @@ const fr = { 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.", + styleFailedDeployment: "Échec du chargement de la carte ({{reason}}). Les enregistrements sont listés ci-dessous ; le style de carte par défaut défini pour ce déploiement n’a pas pu être chargé ici, demandez à votre administrateur de le vérifier.", 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", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index abaa0385822..3c036cd6d9d 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4640,6 +4640,7 @@ const ja = { invalidCoordinatesExcluded: "座標が未設定または無効なため地図から除外されたレコード:{{excluded}}", styleFailedConfigured: "地図を読み込めませんでした({{reason}})。レコードは下に一覧表示しています。このビューに設定された地図スタイルの URL を確認してください。", styleFailedDefault: "地図を読み込めませんでした({{reason}})。レコードは下に一覧表示しています。公開のデフォルト地図タイルをここでは読み込めないため、このビューの地図スタイルを設定するよう管理者に依頼してください。", + styleFailedDeployment: "地図を読み込めませんでした({{reason}})。レコードは下に一覧表示しています。このデプロイに設定されたデフォルトの地図スタイルをここでは読み込めないため、管理者に確認を依頼してください。", styleFailedUnknownReason: "地図スタイルまたはタイルを読み込めませんでした", webgl2Unavailable: "地図を読み込めませんでした(このブラウザーは地図に必要な WebGL2 に対応していません)。レコードは下に一覧表示しています。ハードウェアアクセラレーションを有効にするか、WebGL2 対応のブラウザーで地図を表示してください。", retry: "地図を再読み込み", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 56bfca8c812..51fb7ef2bb6 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4637,6 +4637,7 @@ const ko = { invalidCoordinatesExcluded: "좌표가 없거나 잘못되어 지도에서 제외된 레코드: {{excluded}}", styleFailedConfigured: "지도를 불러오지 못했습니다({{reason}}). 레코드는 아래에 나열되어 있습니다. 이 보기에 설정된 지도 스타일 URL을 확인하세요.", styleFailedDefault: "지도를 불러오지 못했습니다({{reason}}). 레코드는 아래에 나열되어 있습니다. 여기에서는 공개 기본 지도 타일을 불러올 수 없으므로 관리자에게 이 보기의 지도 스타일을 설정해 달라고 요청하세요.", + styleFailedDeployment: "지도를 불러오지 못했습니다({{reason}}). 레코드는 아래에 나열되어 있습니다. 여기에서는 이 배포에 설정된 기본 지도 스타일을 불러올 수 없으므로 관리자에게 확인을 요청하세요.", styleFailedUnknownReason: "지도 스타일 또는 타일을 불러올 수 없습니다", webgl2Unavailable: "지도를 불러오지 못했습니다(이 브라우저는 지도에 필요한 WebGL2를 지원하지 않습니다). 레코드는 아래에 나열되어 있습니다. 하드웨어 가속을 켜거나 WebGL2를 지원하는 브라우저에서 지도를 확인하세요.", retry: "지도 다시 시도", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 8c31b5baa11..3190cad1465 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4749,6 +4749,7 @@ const pt = { 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.", + styleFailedDeployment: "Não foi possível carregar o mapa ({{reason}}). Os registros estão listados abaixo; não foi possível carregar aqui o estilo de mapa padrão definido para esta implantação, então peça ao seu administrador que o verifique.", 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", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index cd205f1bc74..95634b0af47 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4878,6 +4878,7 @@ const ru = { invalidCoordinatesExcluded: "Записи, исключённые с карты из-за отсутствующих или неверных координат: {{excluded}}", styleFailedConfigured: "Не удалось загрузить карту ({{reason}}). Записи перечислены ниже; проверьте URL стиля карты, заданный для этого представления.", styleFailedDefault: "Не удалось загрузить карту ({{reason}}). Записи перечислены ниже; общедоступные тайлы карты по умолчанию здесь загрузить не удалось, попросите администратора задать стиль карты для этого представления.", + styleFailedDeployment: "Не удалось загрузить карту ({{reason}}). Записи перечислены ниже; стиль карты по умолчанию, заданный для этого развёртывания, здесь загрузить не удалось, попросите администратора проверить его.", styleFailedUnknownReason: "не удалось загрузить стиль или тайлы карты", webgl2Unavailable: "Не удалось загрузить карту (этот браузер не поддерживает WebGL2, необходимый для карты). Записи перечислены ниже; включите аппаратное ускорение или откройте карту в браузере с поддержкой WebGL2.", retry: "Повторить загрузку карты", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index aeda6b40d35..e1ed49879d7 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4695,6 +4695,7 @@ const zh = { invalidCoordinatesExcluded: '因坐标缺失或无效而未显示在地图上的记录:{{excluded}}', styleFailedConfigured: '地图加载失败({{reason}})。记录已列在下方;请检查此视图设置的地图样式 URL。', styleFailedDefault: '地图加载失败({{reason}})。记录已列在下方;此处无法加载公共默认地图瓦片,请联系管理员为此视图设置地图样式。', + styleFailedDeployment: '地图加载失败({{reason}})。记录已列在下方;此处无法加载为此部署设置的默认地图样式,请联系管理员检查该样式。', styleFailedUnknownReason: '无法加载地图样式或瓦片', webgl2Unavailable: '地图加载失败(此浏览器不支持地图所需的 WebGL2)。记录已列在下方;请开启硬件加速,或使用支持 WebGL2 的浏览器查看地图。', retry: '重试加载地图', diff --git a/ui/packages/plugin-map/README.md b/ui/packages/plugin-map/README.md index 6ed73d8e603..fc0996db8b5 100644 --- a/ui/packages/plugin-map/README.md +++ b/ui/packages/plugin-map/README.md @@ -148,7 +148,7 @@ required" refusal below rather than a map. | `descriptionField` | Field shown under the title in the marker popup. | | `zoom` | Zoom level. Declaring it opts this view out of the auto-fit (see below). | | `center` | `[latitude, longitude]` — a two-number **tuple**, latitude first. Declaring it opts this view out of the auto-fit. | -| `style` | MapLibre style URL/spec, replacing the default public demo style. The node-level `mapStyle` is read before it, so it applies when `mapStyle` is absent. | +| `style` | MapLibre style URL/spec, replacing the deployment's default style and the public demo style (see "Which style draws"). The node-level `mapStyle` is read before it, so it applies when `mapStyle` is absent. | **Nothing is guessed — an unbound map REFUSES.** A map with no coordinate binding renders @@ -173,10 +173,27 @@ takes its title from the record-title precedence above. | Key | Description | | --- | --- | -| `mapStyle` | MapLibre style URL or spec, replacing the demo tiles. Read before `map.style`, so it wins when both are written. Not the node's base `style`, which is an inline CSS record. | +| `mapStyle` | MapLibre style URL or spec, replacing the deployment's default style and the demo tiles. Read before `map.style`, so it wins when both are written. Not the node's base `style`, which is an inline CSS record. | | `enableClustering` | `true` groups nearby markers into numbered clusters. Absent, the map clusters only above 100 markers; `false` turns clustering off at any count. | | `navigation` | What a marker click opens — the spec's `NavigationConfig`. `drawer`, `modal` and `popover` open the marker's record; `new_window` (or `openNewTab: true`, which outranks every mode except `none`) opens the record page in a new tab; `preventNavigation: true` opens nothing. An absent key, `none` and `split` open nothing (the map hands the split shell no main panel). `page`, and a block without `mode` (the spec's `page` default), open the record page of the map's `objectName` through the record navigator the host publishes (the console publishes one on its custom pages, record pages and list views); under a host that publishes none, or on a map that names no `objectName`, the click opens nothing. A parent view's click handler outranks the whole key. | +## Which style draws + +A map draws the first style it finds, in this order: + +1. the view's own style: the node-level `mapStyle`, then `map.style`; +2. the deployment's default style, which the host hands down through + `HostMapStyleProvider` from `@object-ui/react`. The Console supplies the + operator's `OS_CONSOLE_MAP_STYLE_URL`, served as `mapStyleUrl` on + `GET /api/v1/runtime/config`; +3. the public MapLibre demo style (`https://demotiles.maplibre.org/style.json`), + which is rate-limited and not meant for production traffic. + +A host that provides no style, such as an embedded renderer or a Console whose +operator set none, keeps the demo style. When the style that drew fails to load, +the map is replaced by its record list, and the notice names that style: the +view's own, the deployment's or the public default. + ## Initial camera There is no default zoom and no default centre. With records to show and no diff --git a/ui/packages/plugin-map/src/ObjectMap.deploymentStyle-26163.test.tsx b/ui/packages/plugin-map/src/ObjectMap.deploymentStyle-26163.test.tsx new file mode 100644 index 00000000000..36215be3dcb --- /dev/null +++ b/ui/packages/plugin-map/src/ObjectMap.deploymentStyle-26163.test.tsx @@ -0,0 +1,127 @@ +/** + * 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#26163 — a map view with no style of its own draws the + * deployment's default style before the public demo one. + * + * The operator names the deployment's style in `OS_CONSOLE_MAP_STYLE_URL`; the + * runtime serves it as `mapStyleUrl` on `GET /api/v1/runtime/config` + * (objectstack#26001), and the Console hands it to the renderer through + * `HostMapStyleProvider` from `@object-ui/react`. The order is: + * + * 1. the view's own style (`mapStyle`, then `map.style`); + * 2. the deployment's style, from the host; + * 3. the public demo style. + * + * A host that provides nothing (no env set, an embedded renderer) keeps the + * demo style, and a view's own style still wins over the deployment's. When the + * deployment's style fails to load, the notice names that style as the one that + * failed, never the public default tiles the map did not draw. + */ + +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HostMapStyleProvider } from '@object-ui/react'; +import { ObjectMap } from './ObjectMap'; +import './__tests__/webgl2Available'; + +let capturedProps: any = null; + +vi.mock('react-map-gl/maplibre', () => ({ + default: (props: any) => { + capturedProps = props; + return ( +
+
+ ); + }, + NavigationControl: () =>
, + Marker: ({ children }: any) =>
{children}
, + Popup: ({ children }: any) =>
{children}
, +})); + +const DEMO_STYLE = 'https://demotiles.maplibre.org/style.json'; +const DEPLOYMENT_STYLE = 'https://tiles.intranet.example/styles/basic/style.json'; +const VIEW_STYLE = 'https://tiles.example.com/view-style.json'; + +const records = [{ id: '1', name: 'Loc 1', latitude: 40, longitude: -74 }]; +const MAP = { latitudeField: 'latitude', longitudeField: 'longitude', titleField: 'name' }; + +function mount(schema: any, hostStyle?: string, withProvider = true) { + const map = ; + return render(withProvider ? {map} : map); +} + +async function drawnStyle() { + await screen.findByLabelText('Map'); + await waitFor(() => expect(capturedProps).not.toBeNull()); + return capturedProps.mapStyle; +} + +beforeEach(() => { + capturedProps = null; +}); + +describe('ObjectMap — the deployment default map style (objectstack#26163)', () => { + it('env set, no view style: draws the deployment style', async () => { + mount({ map: MAP }, DEPLOYMENT_STYLE); + expect(await drawnStyle()).toBe(DEPLOYMENT_STYLE); + }); + + it('env set, no view style, flat-form config: draws the deployment style', async () => { + mount({ latitudeField: 'latitude', longitudeField: 'longitude', titleField: 'name' }, DEPLOYMENT_STYLE); + expect(await drawnStyle()).toBe(DEPLOYMENT_STYLE); + }); + + it('env unset (the host provides no style): draws the public demo style', async () => { + mount({ map: MAP }, undefined); + expect(await drawnStyle()).toBe(DEMO_STYLE); + }); + + it('no host provider at all: draws the public demo style', async () => { + mount({ map: MAP }, undefined, false); + expect(await drawnStyle()).toBe(DEMO_STYLE); + }); + + it('a view’s own `mapStyle` wins over the deployment style', async () => { + mount({ mapStyle: VIEW_STYLE, map: MAP }, DEPLOYMENT_STYLE); + expect(await drawnStyle()).toBe(VIEW_STYLE); + }); + + it('a view’s own `map.style` wins over the deployment style', async () => { + mount({ map: { ...MAP, style: VIEW_STYLE } }, DEPLOYMENT_STYLE); + expect(await drawnStyle()).toBe(VIEW_STYLE); + }); + + it('a failed deployment style names the deployment style, not the public default tiles', async () => { + mount({ map: MAP }, DEPLOYMENT_STYLE); + await drawnStyle(); + fireEvent.click(screen.getByTestId('simulate-map-error')); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(`Map failed to load (Failed to fetch ${DEPLOYMENT_STYLE}).`); + expect(alert).toHaveTextContent('the default map style set for this deployment could not be loaded here'); + expect(alert).not.toHaveTextContent('public default map tiles'); + expect(alert).not.toHaveTextContent('check the map style URL set on this view'); + }); + + it('a failed view style still sends the reader to the view, with a deployment style present', async () => { + mount({ mapStyle: VIEW_STYLE, map: MAP }, DEPLOYMENT_STYLE); + await drawnStyle(); + fireEvent.click(screen.getByTestId('simulate-map-error')); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('check the map style URL set on this view'); + }); +}); diff --git a/ui/packages/plugin-map/src/ObjectMap.tsx b/ui/packages/plugin-map/src/ObjectMap.tsx index c1b8fedeb40..55c9ca92a71 100644 --- a/ui/packages/plugin-map/src/ObjectMap.tsx +++ b/ui/packages/plugin-map/src/ObjectMap.tsx @@ -31,6 +31,7 @@ import { useFilterScope, useResolvedFilter, useObjectTranslation, + useHostMapStyle, } from '@object-ui/react'; import { NavigationOverlay, cn, useIsMobile } from '@object-ui/components'; import { usePermissions } from '@object-ui/permissions'; @@ -61,9 +62,11 @@ 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 swaps the map for the record list - * below rather than leaving a grey canvas. + * production traffic (rate-limited, no uptime guarantee). The LAST fallback + * (objectstack#26163): used only when the view configures no style and the + * host provides no deployment default (`useHostMapStyle`, the Console's + * runtime-config `mapStyleUrl`). 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'; @@ -792,6 +795,18 @@ export const ObjectMap: React.FC = ({ * equality. */ const mapConfig = useMemo(() => getMapConfig(schema), [schema]); + // The style the map draws, and whose it is (objectstack#26163): the view's + // own (`mapStyle`, then `map.style`, already folded into `mapConfig.style`), + // then the deployment's default the host provides (the operator's + // `OS_CONSOLE_MAP_STYLE_URL`, served as runtime-config `mapStyleUrl`), then + // the public demo style. The owner picks the failure notice's remedy below. + const hostMapStyle = useHostMapStyle(); + const mapStyleOwner: 'view' | 'deployment' | 'default' = mapConfig.style + ? 'view' + : hostMapStyle + ? 'deployment' + : 'default'; + const drawnMapStyle = mapConfig.style || hostMapStyle || DEFAULT_MAP_STYLE; const hasInlineData = dataConfig?.provider === 'value'; /** @@ -1381,10 +1396,11 @@ 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. + // Why the record list stands where the map should be. Each remedy names the + // style that actually failed: a configured style is this view's own to check; + // the deployment's default (objectstack#26163) is the administrator's, who set + // it; 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' }); @@ -1393,17 +1409,23 @@ export const ObjectMap: React.FC = ({ 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 + : mapStyleOwner === 'view' ? 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.', - }); + : mapStyleOwner === 'deployment' + ? t('element.map.styleFailedDeployment', { + reason: styleFailureReason, + defaultValue: + 'Map failed to load ({{reason}}). The records are listed below; the default map style set for this deployment could not be loaded here, so ask your administrator to check it.', + }) + : 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 (
@@ -1487,7 +1509,7 @@ export const ObjectMap: React.FC = ({ ref={(r) => { mapRef.current = r as any; }} initialViewState={initialViewState} style={{ width: '100%', height: '100%' }} - mapStyle={mapConfig.style || DEFAULT_MAP_STYLE} + mapStyle={drawnMapStyle} touchZoomRotate={true} dragRotate={true} touchPitch={true} diff --git a/ui/packages/react/src/context/HostMapStyleContext.tsx b/ui/packages/react/src/context/HostMapStyleContext.tsx new file mode 100644 index 00000000000..a67ac0db76e --- /dev/null +++ b/ui/packages/react/src/context/HostMapStyleContext.tsx @@ -0,0 +1,60 @@ +/** + * 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. + */ + +/** + * HostMapStyleContext — the deployment's default map style, offered to the map + * renderer by its host (objectstack#26163, the renderer half of #26001). + * + * A map view that declares no style of its own used to draw the public MapLibre + * demo style, a host an intranet or proxied deployment cannot reach. The + * operator names the deployment's own style in `OS_CONSOLE_MAP_STYLE_URL`, and + * the runtime serves it as `mapStyleUrl` on `GET /api/v1/runtime/config`. Only + * the host reads that payload, so the host hands the value down through this + * context. The map renderer reads it AFTER the view's own style and BEFORE the + * public demo default. + * + * ## Why a context, and why here + * + * The map renderer reaches the page through `ComponentRegistry`, so no host + * prop reaches it; a context does, the same way `HostNavigationContext` + * reaches every registered renderer. The context lives in this package rather + * than in `@object-ui/plugin-map` because the Console loads that plugin lazily + * (`registerLazy`, to keep maplibre-gl out of the first bundle). A provider the + * Console imported from the plugin would load the plugin, and maplibre-gl + * with it, on every page. Both the plugin and the Console already depend on + * this package, so neither gains a dependency. + * + * ## Absent is a supported state + * + * Outside a provider, or under one given no style, `useHostMapStyle()` answers + * `undefined` and the map draws what it drew before. Hosts that do not serve + * the runtime payload (an embedded renderer, a test) change nothing. + * + * ⛔ Not a metadata key. A view's own style stays `mapStyle` / `map.style`, and + * nothing here is authorable: the value is the operator's (ruling A on #26001). + */ + +import React, { createContext, useContext } from 'react'; + +export const HostMapStyleContext = createContext(undefined); + +export const HostMapStyleProvider: React.FC<{ + /** The deployment's default MapLibre style URL; `undefined` when none is set. */ + styleUrl: string | undefined; + children: React.ReactNode; +}> = ({ styleUrl, children }) => ( + {children} +); + +/** + * The deployment's default map style the host provided, or `undefined` when no + * host provided one. See the module docblock. + */ +export function useHostMapStyle(): string | undefined { + return useContext(HostMapStyleContext); +} diff --git a/ui/packages/react/src/context/index.ts b/ui/packages/react/src/context/index.ts index e19c50939f8..d4b834cd142 100644 --- a/ui/packages/react/src/context/index.ts +++ b/ui/packages/react/src/context/index.ts @@ -10,5 +10,6 @@ export * from './InlineEditContext.js'; export * from './DiscussionContext.js'; export * from './DrillNavigationContext.js'; export * from './HostNavigationContext.js'; +export * from './HostMapStyleContext.js'; export * from './RelatedRecordActionsContext.js'; export * from './PrintPageContext.js';