Skip to content

feat(user): persist per-user UI preferences on the User entity (starting with appMode) - #31030

Open
chirag-madlani wants to merge 4 commits into
mainfrom
feat/user-preferences-backend
Open

feat(user): persist per-user UI preferences on the User entity (starting with appMode)#31030
chirag-madlani wants to merge 4 commits into
mainfrom
feat/user-preferences-backend

Conversation

@chirag-madlani

Copy link
Copy Markdown
Collaborator

Summary

Persist the user's app-mode preference (AI vs Classic) — and any future opaque per-user UI preference — on the backend User entity, so the choice follows the user across devices, browsers, and profiles instead of only living in localStorage.

What changed

Schema — openmetadata-spec/.../teams/user.json

  • New optional preferences: Map<String, Object> field, opaque to the server, stored inside the existing user_entity.json JSON blob. No DB migration. existingJavaType directive ensures the Java shape is Map<String, Object> rather than a generated wrapper POJO.

Backend — openmetadata-service

  • UserRepository.clearFields gates preferences behind fields=preferences. The field is serialised only when explicitly requested — never in list responses, and never in other users' GETs.
  • UserResource.getCurrentLoggedInUser forces preferences into its fetched field set via EntityUtil.addField, so the UI can hydrate on bootstrap without every caller needing to remember the query param.
  • Swagger FIELDS example updated. allowedFields is auto-derived from @JsonPropertyOrder on generated User.java, so no manual allow-list edit was needed.

UI — openmetadata-ui/.../hooks/currentUserStore/useCurrentUserStore.ts

  • New BACKEND_SYNCED_KEYS = new Set(['appMode']) — the whitelist of preference keys that also live on the backend. All other UserPreferences fields remain purely local (persisted by the existing persist middleware, unchanged).
  • hydrateBackendSyncedPreferences(user) is called from AuthProvider right after getLoggedInUser() resolves (both the returning-session bootstrap and the interactive-login paths). It:
    • Server wins when the server has a value: local slice is overwritten.
    • One-shot migration when the server has no value but local does: emits a JSON-Patch add up to the backend.
  • setPreference(patch) — the consumer-facing hook — is unchanged. Under the hood, writes to whitelisted keys additionally enqueue a 300ms-debounced updateUserDetail(userId, ops) JSON-Patch:
    • nullremove, key not previously on server → add, otherwise → replace. Coalesces rapid writes into one PATCH.
  • On PATCH failure: local state rolls back to the last server-confirmed value and showErrorToast fires. No silent divergence between client and server.
  • beforeunload flushes any pending debounced patch best-effort.
  • persist middleware is not removed — sidebar collapse state, recently-viewed, etc. still live on localStorage as before.

Types

  • openmetadata-ui/.../generated/entity/teams/user.ts regenerated with the new preferences? field.

Testing

  • Java IT tests (openmetadata-integration-tests/.../UserResourceIT.java) — 4 tests added:

    • patch_preferences_add_appMode_returnsIt — JSON-Patch add round-trip.
    • get_user_without_fields_omitsPreferences — field-gating works both directions (without fields=preferences, the response omits it; with it, the response carries it).
    • patch_preferences_replaceOp_persistsreplace op semantics.
    • patch_preferences_removeOp_clearsKeyremove op semantics.
    • list_users_omitsPreferencesForAllEntries — list-response omission across a page.

    These tests were drafted but not executed locally — they need Docker/MySQL/ES containers that weren't available in the authoring environment. CI on this PR is the first real run.

  • UI Jest tests (useCurrentUserStore.test.ts) — 9 new tests, all passing locally:

    • Hydration overrides local appMode when server has a value (server wins).
    • Hydration leaves local alone when neither has a value.
    • setPreference({ appMode }) fires a debounced PATCH.
    • Rapid writes coalesce into a single PATCH with the last value.
    • Non-whitelisted keys do not PATCH but still write locally.
    • appMode: null emits a remove op.
    • PATCH failure rolls back local state and toasts.
    • Local appMode migrates to backend when the server has none.
    • No migration when the server already has a value.

    Full store test suite: 15/15 passing. Pre-existing AuthProvider.test.tsx (12 tests) still passing after wiring the bootstrap call.

Design notes / decisions

  • preferences is opaque server-side. No validation of individual keys or values — the schema/enum lives in the UI. This keeps future preferences (like connectionsViewMode, or Collate-only keys) landable without schema changes.
  • P1 whitelist scope. Only appMode is backend-synced in this PR. Other UserPreferences fields (sidebar collapse state, recently viewed, marketplace history, etc.) intentionally stay per-device. A future PR can categorize each and expand BACKEND_SYNCED_KEYS.
  • Field-gating avoids leaking preferences across users. Without fields=preferences, GET /users/{id} and list responses omit the field entirely. Only the loggedInUser endpoint opts in by default.
  • existingJavaType on the schema prevents jsonschema2pojo from generating a wrapper Preferences POJO with @JsonAnyGetter/@JsonAnySetter, which would have made the Java handling inconsistent with the "opaque bag" design.
  • Debounce = 300ms, coalesced per key. Enough to swallow rapid toggle bursts; short enough that a user tabbing away sees the write land.

Follow-ups (out of scope for this PR)

  • Categorize other UserPreferences fields and expand the backend-synced whitelist (Collate's AppModeSwitcher shipped appMode first, other fields will follow if they cross device).
  • Consider preferences versioning if the shape ever needs breaking changes on the wire.

Test plan for reviewers

  • CI passes (both the Java IT tests and the UI Jest tests).
  • Manual smoke: log in on browser A, toggle app mode → PATCH /v1/users/{id} fires within ~300ms with op: add|replace|remove on /preferences/appMode.
  • Log in on browser B with same user → getLoggedInUser returns preferences.appMode; AppModeSwitcher reflects the choice.
  • GET /v1/users/{id} (without fields=preferences) returns preferences: null.
  • GET /v1/users list returns every user with preferences: null.

🤖 Generated with Claude Code

Adds a `preferences` property to the User JSON schema as an opaque,
server-side Map<String, Object> bag (existingJavaType pins it to
java.util.Map<java.lang.String, java.lang.Object> instead of letting
jsonschema2pojo generate a wrapper POJO). Default is {} so downstream
code can iterate without null checks.

Adds UserResourceIT#patch_preferences_add_appMode_returnsIt, which
PATCHes preferences.appMode via JSON-Patch and reads it back with
?fields=preferences.

No repository/resource-level validation or field-gating is added here
(opaque by design) — that is scoped to a follow-up task.
Copilot AI review requested due to automatic review settings August 5, 2026 12:17
@chirag-madlani
chirag-madlani requested review from a team as code owners August 5, 2026 12:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

❌ PR checklist incomplete

This PR cannot be merged until the following are addressed on its linked issue:

  • No GitHub issue is linked. Link an issue in the Development section of the PR (or add Fixes #12345 to the description). For a same-org cross-repo issue, add Fixes open-metadata/<repo>#123 to the description.

The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically.

Maintainers can bypass this check by adding the skip-pr-checks label.

@github-actions github-actions Bot added safe to test Add this label to run secure Github workflows on PRs UI UI specific issues labels Aug 5, 2026
Comment on lines +125 to +128
const pendingPatch = new Map<string, unknown>();
const previousValues = new Map<string, unknown>();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
let serverKnown: Partial<UserPreferences> = {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: resetBackendSyncState never called on logout — module state leaks across sessions

The module-level sync bookkeeping (pendingPatch, previousValues, flushTimer, serverKnown) lives at module scope and is only reset via resetBackendSyncState, which the comment claims is "safe to call on logout" but is in fact never invoked anywhere outside the store file and its test (verified via grep). Because syncBackendKeys short-circuits when flushTimer !== null, a pending debounce from user A that survives a logout/login on the same tab will (a) prevent user B's writes and the one-shot migration in hydrateBackendSyncedPreferences from scheduling a new flush, and (b) fire flushPendingPatch(userA.name, userA.id) while pendingPatch now holds user B's value — writing B's preference to A's id. Wire resetBackendSyncState() into the logout path (e.g. onLogoutHandler/handleSuccessfulLogout) so state does not carry across users.

Was this helpful? React with 👍 / 👎

return { op: 'remove', path: `/preferences/${key}` } as Operation;
}

const op = key in serverKnown ? 'replace' : 'add';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: serverKnown not updated after field-gated PATCH response omits preferences

After a successful PATCH, flushPendingPatch seeds serverKnown from updated?.preferences ?? {}. But per the new field-gating in UserRepository.clearFields, the PATCH response omits preferences unless fields=preferences is requested, so updated.preferences is null and serverKnown never records the added key. Consequently the op for a later write is computed as add instead of replace (line 150). JSON-Patch add on an existing object member behaves like replace per RFC 6902, so it works, but the intent-tracking is effectively dead. Either request fields=preferences on the PATCH round-trip or drop the add/replace distinction. Note the UI test mocks a response that does carry preferences, which may not reflect the real endpoint.

Was this helpful? React with 👍 / 👎

Comment on lines +134 to +148
flushTimer = null;
if (pendingPatch.size === 0) {
return;
}

const attempted = new Map(pendingPatch);
pendingPatch.clear();
const attemptedPrevious = new Map(previousValues);
previousValues.clear();

const ops: Operation[] = Array.from(attempted.entries()).map(
([key, value]) => {
if (value === null) {
return { op: 'remove', path: `/preferences/${key}` } as Operation;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: In-flight PATCH failure can roll back a newer optimistic write

flushPendingPatch clears pendingPatch/previousValues and sets flushTimer = null before awaiting updateUserDetail. A user write during the in-flight request starts a fresh debounce cycle with a new optimistic local value. If the earlier request then rejects, the catch handler calls setUserPreference(userName, rollback) with the stale attemptedPrevious, clobbering the newer value the user just set. Consider skipping rollback for keys whose local value has changed since the attempt, or serialising flushes so a new write isn't overwritten by a failed older one.

Was this helpful? React with 👍 / 👎

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

✅ TypeScript Types Auto-Updated

The generated TypeScript types have been automatically updated based on JSON schema changes in this PR.

Copilot AI review requested due to automatic review settings August 5, 2026 12:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

❌ UI Checkstyle Failed

❌ ESLint + Prettier + Organise Imports (src)

One or more source files have linting or formatting issues.

Affected files
  • openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
    • openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.test.ts
    • openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts

🔍 ESLint findings in this PR's files — 0 error(s), 22 warning(s)

Errors block the build. Warnings do not yet — they are rules whose backlog is still
being worked down, listed so this PR does not add to it. See docs/ui-code-quality-gate.md.

0 error(s), 22 warning(s) across 3 changed file(s).

Count Rule
9 react-hooks/exhaustive-deps
7 @typescript-eslint/no-explicit-any
3 sonarjs/no-nested-functions
2 sonarjs/cyclomatic-complexity
1 sonarjs/no-duplicate-string
All findings
Location Rule Message
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:185:9 react-hooks/exhaustive-deps The 'onLoginHandler' function makes the dependencies of useMemo Hook (at line 832) change on every render. Move it inside the useMemo callback. Alternatively, w
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:257:6 react-hooks/exhaustive-deps React Hook useCallback has missing dependencies: 'navigate', 'setApplicationLoading', 'setCurrentUser', and 'setIsAuthenticated'. Either include them or remove
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:315:9 react-hooks/exhaustive-deps The 'resetUserDetails' function makes the dependencies of useMemo Hook (at line 832) change on every render. To fix this, wrap the definition of 'resetUserDetai
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:401:6 react-hooks/exhaustive-deps React Hook useEffect has a missing dependency: 'startTokenExpiryTimer'. Either include it or remove the dependency array.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:444:6 react-hooks/exhaustive-deps React Hook useEffect has a missing dependency: 'startTokenExpiryTimer'. Either include it or remove the dependency array.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:454:9 react-hooks/exhaustive-deps The 'handleFailedLogin' function makes the dependencies of useMemo Hook (at line 832) change on every render. Move it inside the useMemo callback. Alternatively
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:516:5 react-hooks/exhaustive-deps React Hook useCallback has missing dependencies: 'authConfig?.provider', 'handledVerifiedUser', 'navigate', 'resetUserDetails', and 'startTokenExpiryTimer'. Eit
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:561:9 react-hooks/exhaustive-deps The 'initializeAxiosInterceptors' function makes the dependencies of useMemo Hook (at line 832) change on every render. To fix this, wrap the definition of 'ini
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:637:65 sonarjs/no-nested-functions Refactor this code to not nest functions more than 4 levels deep.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:647:39 sonarjs/no-nested-functions Refactor this code to not nest functions more than 4 levels deep.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:662:41 sonarjs/no-nested-functions Refactor this code to not nest functions more than 4 levels deep.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:732:30 sonarjs/cyclomatic-complexity {"message":"Function has a complexity of 17 which is greater than 10 authorized.","cost":7,"secondaryLocations":[{"line":732,"column":29,"endLine":732,"endColum
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:821:6 react-hooks/exhaustive-deps React Hook useEffect has missing dependencies: 'cleanup', 'fetchAuthConfig', 'initializeAxiosInterceptors', and 'startTokenExpiryTimer'. Either include them or
🟡 src/hooks/currentUserStore/useCurrentUserStore.test.ts:63:10 @typescript-eslint/no-explicit-any Unexpected any. Specify a different type.
🟡 src/hooks/currentUserStore/useCurrentUserStore.test.ts:95:12 @typescript-eslint/no-explicit-any Unexpected any. Specify a different type.
🟡 src/hooks/currentUserStore/useCurrentUserStore.test.ts:138:14 @typescript-eslint/no-explicit-any Unexpected any. Specify a different type.
🟡 src/hooks/currentUserStore/useCurrentUserStore.test.ts:164:14 @typescript-eslint/no-explicit-any Unexpected any. Specify a different type.
🟡 src/hooks/currentUserStore/useCurrentUserStore.test.ts:177:16 @typescript-eslint/no-explicit-any Unexpected any. Specify a different type.
🟡 src/hooks/currentUserStore/useCurrentUserStore.test.ts:201:14 @typescript-eslint/no-explicit-any Unexpected any. Specify a different type.
🟡 src/hooks/currentUserStore/useCurrentUserStore.test.ts:245:14 @typescript-eslint/no-explicit-any Unexpected any. Specify a different type.
🟡 src/hooks/currentUserStore/useCurrentUserStore.test.ts:314:28 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/hooks/currentUserStore/useCurrentUserStore.ts:190:17 sonarjs/cyclomatic-complexity {"message":"Function has a complexity of 11 which is greater than 10 authorized.","cost":1,"secondaryLocations":[{"line":190,"column":16,"endLine":190,"endColum

Fix locally (fast - only checks files changed in this branch):

make ui-checkstyle-changed

@gitar-bot

gitar-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown
Code Review ⚠️ Changes requested 0 resolved / 3 findings

Adds backend persistence and cross-device sync for per-user UI preferences starting with appMode, but changes are requested due to session state leaking on logout in resetBackendSyncState, serverKnown not updating after field-gated PATCH responses omit preferences, and race conditions where in-flight PATCH failures roll back newer optimistic writes.

⚠️ Bug: resetBackendSyncState never called on logout — module state leaks across sessions

📄 openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:125-128 📄 openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:216-222 📄 openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:272-280 📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx:212-226

The module-level sync bookkeeping (pendingPatch, previousValues, flushTimer, serverKnown) lives at module scope and is only reset via resetBackendSyncState, which the comment claims is "safe to call on logout" but is in fact never invoked anywhere outside the store file and its test (verified via grep). Because syncBackendKeys short-circuits when flushTimer !== null, a pending debounce from user A that survives a logout/login on the same tab will (a) prevent user B's writes and the one-shot migration in hydrateBackendSyncedPreferences from scheduling a new flush, and (b) fire flushPendingPatch(userA.name, userA.id) while pendingPatch now holds user B's value — writing B's preference to A's id. Wire resetBackendSyncState() into the logout path (e.g. onLogoutHandler/handleSuccessfulLogout) so state does not carry across users.

💡 Bug: serverKnown not updated after field-gated PATCH response omits preferences

📄 openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:150 📄 openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:157-167 📄 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java:451

After a successful PATCH, flushPendingPatch seeds serverKnown from updated?.preferences ?? {}. But per the new field-gating in UserRepository.clearFields, the PATCH response omits preferences unless fields=preferences is requested, so updated.preferences is null and serverKnown never records the added key. Consequently the op for a later write is computed as add instead of replace (line 150). JSON-Patch add on an existing object member behaves like replace per RFC 6902, so it works, but the intent-tracking is effectively dead. Either request fields=preferences on the PATCH round-trip or drop the add/replace distinction. Note the UI test mocks a response that does carry preferences, which may not reflect the real endpoint.

💡 Edge Case: In-flight PATCH failure can roll back a newer optimistic write

📄 openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:134-148

flushPendingPatch clears pendingPatch/previousValues and sets flushTimer = null before awaiting updateUserDetail. A user write during the in-flight request starts a fresh debounce cycle with a new optimistic local value. If the earlier request then rejects, the catch handler calls setUserPreference(userName, rollback) with the stale attemptedPrevious, clobbering the newer value the user just set. Consider skipping rollback for keys whose local value has changed since the attempt, or serialising flushes so a new write isn't overwritten by a failed older one.

🤖 Prompt for agents
Code Review: Adds backend persistence and cross-device sync for per-user UI preferences starting with appMode, but changes are requested due to session state leaking on logout in resetBackendSyncState, serverKnown not updating after field-gated PATCH responses omit preferences, and race conditions where in-flight PATCH failures roll back newer optimistic writes.

1. ⚠️ Bug: resetBackendSyncState never called on logout — module state leaks across sessions
   Files: openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:125-128, openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:216-222, openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:272-280, openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx:212-226

   The module-level sync bookkeeping (`pendingPatch`, `previousValues`, `flushTimer`, `serverKnown`) lives at module scope and is only reset via `resetBackendSyncState`, which the comment claims is "safe to call on logout" but is in fact never invoked anywhere outside the store file and its test (verified via grep). Because `syncBackendKeys` short-circuits when `flushTimer !== null`, a pending debounce from user A that survives a logout/login on the same tab will (a) prevent user B's writes and the one-shot migration in `hydrateBackendSyncedPreferences` from scheduling a new flush, and (b) fire `flushPendingPatch(userA.name, userA.id)` while `pendingPatch` now holds user B's value — writing B's preference to A's id. Wire `resetBackendSyncState()` into the logout path (e.g. `onLogoutHandler`/`handleSuccessfulLogout`) so state does not carry across users.

2. 💡 Bug: serverKnown not updated after field-gated PATCH response omits preferences
   Files: openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:150, openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:157-167, openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java:451

   After a successful PATCH, `flushPendingPatch` seeds `serverKnown` from `updated?.preferences ?? {}`. But per the new field-gating in `UserRepository.clearFields`, the PATCH response omits `preferences` unless `fields=preferences` is requested, so `updated.preferences` is `null` and `serverKnown` never records the added key. Consequently the op for a later write is computed as `add` instead of `replace` (line 150). JSON-Patch `add` on an existing object member behaves like replace per RFC 6902, so it works, but the intent-tracking is effectively dead. Either request `fields=preferences` on the PATCH round-trip or drop the add/replace distinction. Note the UI test mocks a response that does carry `preferences`, which may not reflect the real endpoint.

3. 💡 Edge Case: In-flight PATCH failure can roll back a newer optimistic write
   Files: openmetadata-ui/src/main/resources/ui/src/hooks/currentUserStore/useCurrentUserStore.ts:134-148

   `flushPendingPatch` clears `pendingPatch`/`previousValues` and sets `flushTimer = null` before awaiting `updateUserDetail`. A user write during the in-flight request starts a fresh debounce cycle with a new optimistic local value. If the earlier request then rejects, the catch handler calls `setUserPreference(userName, rollback)` with the stale `attemptedPrevious`, clobbering the newer value the user just set. Consider skipping rollback for keys whose local value has changed since the attempt, or serialising flushes so a new write isn't overwritten by a failed older one.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar | Powered by Gitar — free for open source

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Jest test Coverage

UI tests summary

Lines Statements Branches Functions
Coverage: 66%
66.13% (77974/117893) 50.09% (47083/93984) 51.27% (14144/27584)

@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

safe to test Add this label to run secure Github workflows on PRs UI UI specific issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants