diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml index 14f383f09ec8..73b6819aea81 100644 --- a/conf/openmetadata.yaml +++ b/conf/openmetadata.yaml @@ -25,7 +25,24 @@ server: applicationContextPath: ${BASE_PATH:-/} rootPath: /api/* applicationConnectors: - - type: http + # + # Connector protocol. Two supported values: + # + # - http (default): HTTP/1.1 only. Compatible with every load balancer and proxy + # ever shipped. Pick this if you front Jetty with an HTTP/2-terminating LB + # (AWS ALB / CloudFront / nginx / Caddy) — those speak HTTP/2 to the browser + # but downgrade to HTTP/1.1 on the upstream hop anyway, so HTTP/2 on Jetty + # buys nothing. + # + # - h2c HTTP/2 cleartext on the same TCP port. Jetty 12 accepts both h1 clients + # (via h1 protocol) and h2 clients (via h2c-upgrade or h2c-prior-knowledge), + # so this is backwards-compatible. Worth flipping on when browsers hit Jetty + # directly (Docker Compose, on-prem single-node, local dev) — multiplexing + # removes the h1 6-connections-per-origin cap, which materially helps cold + # first-paint on SPAs that load 30+ chunks. + # + # Requires the dropwizard-http2 module (already pulled in by openmetadata-service). + - type: ${SERVER_PROTOCOL:-http} bindHost: ${SERVER_HOST:-0.0.0.0} port: ${SERVER_PORT:-8585} diff --git a/docs/perf/cdn-deployment-guide.md b/docs/perf/cdn-deployment-guide.md new file mode 100644 index 000000000000..f8f4f60bd98f --- /dev/null +++ b/docs/perf/cdn-deployment-guide.md @@ -0,0 +1,295 @@ +# Shipping Collate / OpenMetadata releases through CloudFront + +Each customer gets a Collate deployment at their own host — +`acme.getcolate.io`, `widgets.getcolate.io`, `globex.getcolate.io` — and each customer can +be on a different release. This is the AWS-only design for serving the UI bundle from +CloudFront in that model, and the coordination story when a request lands at one of those +hosts. + +## What we want + +- **One CloudFront distribution** for every customer (not one per customer). +- **One S3 bucket** for every release. Releases are immutable; promotion is a separate + step from upload. +- **Per-customer version pinning** that updates atomically — no DNS change, no CloudFront + redeploy. +- **Customer's own ALB** continues to serve `/api/*`; CloudFront only handles the UI bundle. + +## What we explicitly do NOT want + +- A new external data store to maintain (DynamoDB, an extra RDS, a separate Redis). The + customer-version mapping is small (a few hundred entries, two tiny strings each) and + changes rarely (a few writes per week, even at peak). Standing up a data store for that + buys nothing and adds backup, monitoring, IAM, and cost surface. +- Per-customer CloudFront distributions. They give clean isolation but at N customers we + have N distributions to manage, N caches that share no edge state across customers, and + hit the AWS 200-distributions-per-account cap by default. The savings from edge cache + sharing (a thousand customers on v1.12.0 hit the same cached chunk) are the entire + reason the shared model is worth using. +- A lookup that requires Lambda@Edge. The cold start and per-request cost is real + ($1+/M, plus 30-60 ms when cold) and we don't need the SDK access Lambda@Edge gives. + +## The architecture + +``` + ┌──────────────────────────────────────┐ +acme.getcolate.io ───┐ │ CloudFront distribution │ +widgets.getcolate.io ───┼─────►│ d1234abc.cloudfront.net │ +globex.getcolate.io ───┘ │ │ + │ ┌─ behavior: /* ──────────────────┐ │ + │ │ origin: S3 │ │ ┌─────────────────────────────┐ + │ │ viewer-request: host_router.js │─┼────►│ S3: collate-cdn │ + │ │ rewrites /foo → │ │ │ release/v1.11.2/index.html │ + │ │ /release//foo │ │ │ release/v1.12.0/index.html │ + │ └──────────────────────────────────┘ │ │ release/v1.13.0-beta/... │ + │ │ └─────────────────────────────┘ + │ ┌─ behavior: /api/* ──────────────┐ │ + │ │ bypass: same host's per- │ │ ┌─────────────────────────────┐ + │ │ customer ALB (Option A below) │─┼────►│ Each customer's own ALB │ + │ └──────────────────────────────────┘ │ └─────────────────────────────┘ + └──────────────────────────────────────┘ +``` + +The CloudFront Function holds the customer→version routing table **as JavaScript object +literal**. Source of truth is the Function's source code in our git repo. Promotion is +a Function code update. + +## The Function (no external lookup) + +```js +// host_router.js — CloudFront Function v2.0 (no Lambda@Edge, no KVS, no DynamoDB) +// +// Source of truth for which release each customer is pinned to. Edit, commit, deploy. +// CI propagates a change to every edge POP in ~60 s. + +const CUSTOMER_VERSIONS = { + acme: 'v1.12.0', + widgets: 'v1.11.2', + globex: 'v1.13.0-beta', + // … N customers +}; + +// Hosts that don't match a customer slug (apex, www., staging) fall back to the latest +// stable release. Bump this in lockstep with every GA release so new customers that +// haven't been added to CUSTOMER_VERSIONS yet still get a current build. +const DEFAULT_VERSION = 'v1.12.0'; + +function handler(event) { + const request = event.request; + + // /api/* lives on a separate behavior with the customer's own ALB as origin. + // The Function should never see these requests under the current behavior config, + // but guard anyway. + if (request.uri.startsWith('/api/')) { + return request; + } + + const host = (request.headers.host && request.headers.host.value) || ''; + // Convention: customer slug is the first label of the host. + // acme.getcolate.io -> 'acme' + const slug = host.split('.')[0]; + const version = CUSTOMER_VERSIONS[slug] || DEFAULT_VERSION; + + // /assets/foo.js -> /release/v1.12.0/assets/foo.js + request.uri = '/release/' + version + request.uri; + return request; +} +``` + +Function v2.0 has a 10 KB code limit. At ~30 bytes per entry that's ~300 customers +comfortably; well beyond that the design needs revisiting — but if you ever reach 300+ +customers on this product, the operational economics of standing up KVS or DynamoDB +will have shifted significantly anyway. + +## Promotion flow + +1. Edit `CUSTOMER_VERSIONS` in the Function source. +2. Commit, push, open PR. The PR diff IS the promotion record — reviewable, auditable, + git-blame'd. +3. CI runs on merge: pushes the new Function code via `aws cloudfront update-function` + and `publish-function`. +4. ~60 s of edge propagation. Every POP picks up the new code. + +A typical promotion PR looks like one line changed: + +```diff + const CUSTOMER_VERSIONS = { +- acme: 'v1.12.0', ++ acme: 'v1.12.1', + widgets: 'v1.11.2', + globex: 'v1.13.0-beta', + }; +``` + +That's the entire surface area of a promotion. No DynamoDB write. No KVS API call. No +extra IAM role. No backup story. Just a code change reviewed like any other. + +Rollback is symmetric: revert the commit. Canary is "promote one slug first, watch error +metrics, then PR the next batch." Roll-forward on a regression is the same revert. + +### Release upload (independent of promotion) + +The bundle bytes go to S3 separately, on every release tag, regardless of which customer +ends up using them: + +```bash +VERSION="v1.12.0" +aws s3 sync openmetadata-ui/src/main/resources/ui/dist/assets/ \ + s3://collate-cdn/release/${VERSION}/assets/ \ + --cache-control "public, max-age=31536000, immutable" + +aws s3 cp openmetadata-ui/src/main/resources/ui/dist/index.html \ + s3://collate-cdn/release/${VERSION}/index.html \ + --cache-control "no-cache, must-revalidate" \ + --content-type "text/html; charset=utf-8" +``` + +After this, the release exists in S3 but no customer is using it. Promotion (the PR +above) is what flips customers to it. The decoupling matters: you can sit on a release +in S3 for a week, watching it on staging, before promoting any customer to it. + +## Why the Function code is a fine routing table + +Honest comparison of the three approaches: + +| | Function-embedded (this design) | CloudFront KeyValueStore | DynamoDB + Lambda@Edge | +|---|---|---|---| +| New AWS service to monitor / back up | none | KVS | DynamoDB + Lambda | +| Read latency at edge | ~0 (in-function) | ~1 ms | ~10 ms (warm Lambda) | +| Cold start | none | none | 30-60 ms | +| Per-request cost | $0.10/M Function | $0.10/M Function + $0.04/M KVS | $0.10/M + $1+/M Lambda + DynamoDB reads | +| Promotion surface | git PR | API call (`put-key`) | API call (`update-item`) | +| Audit trail | git history | CloudWatch + KVS audit logs | CloudWatch + DDB streams | +| Capacity ceiling | ~300 customers (10 KB code limit) | millions | millions | +| Concurrent promotion safety | git merge serializes | `IfMatch` ETag | conditional writes | +| Operational ownership | "this is in the repo" | "who paged on this last quarter?" | "who paged on this last quarter?" | + +For a product that ships per-customer clusters and reaches dozens-to-low-hundreds of +customers, "the routing table is a file in the repo" wins on every operational axis that +matters. It only loses on capacity ceiling, and the day that becomes a problem we already +have a clear migration target (KVS) without changing anything else in the design. + +## API routing — two options, pick one + +The Function above only handles UI bundle requests. `/api/*` still has to reach the +customer's own ALB. + +### Option A — Separate API host (recommended) + +``` +acme.getcolate.io → CNAME → CloudFront distribution (this design) +api-acme.getcolate.io → CNAME → acme's ALB +``` + +SPA's API base URL is derived from the page host at runtime: `https://api-{slug}.getcolate.io/api`. + +Pros: CloudFront does one thing well (static delivery). No Lambda@Edge anywhere. Failure +modes are easy to reason about. Cons: SPA has a cookie/CORS story that knows about two +hosts; we already handle this for various integrations. + +### Option B — Same host, Lambda@Edge for `/api/*` + +CloudFront's `/api/*` behavior runs a Lambda@Edge on origin-request that reads the host +header and rewrites the origin to the right ALB. + +Pros: single host per customer. Cons: now we DO have Lambda@Edge (which we explicitly +chose to avoid for routing), and the operational cost is per-customer-API-request, not +just per-promotion. We strongly prefer Option A. + +## S3 bucket layout + +``` +collate-cdn/ +└── release/ + ├── v1.11.5/ + │ ├── index.html no-cache, must-revalidate + │ ├── assets/index-Z3O_FBkA.js immutable + │ ├── assets/index-Z3O_FBkA.js.br immutable + │ ├── assets/index-Z3O_FBkA.js.gz immutable + │ ├── assets/vendor-antd-BgrjOjhB.js immutable + │ └── ... + ├── v1.12.0/ ← acme + widgets currently here + │ └── ... + └── v1.13.0-beta/ ← globex currently here (canary) + └── ... +``` + +Releases are immutable once uploaded. The promotion step never modifies S3 contents — +only the Function code that maps `slug → /release//`. + +Disk cost is small: a typical OM bundle is ~12 MB on disk after content-hash dedup, +Brotli+gzip siblings add ~25%, call it 15 MB per release. 100 releases × 15 MB = +1.5 GB. S3 standard rates put that at a few cents per month — keep many releases live +for instant rollback and don't bother with aggressive lifecycle pruning. + +## CloudFront cache behaviors + +| Path pattern (after Function rewrite) | Edge TTL | Notes | +|---|---|---| +| `/release//assets/*` | 1 year | Content-addressed; bytes can't change | +| `/release//index.html`, `/release//` | 30 s | Concurrent users in one region share one origin hit; ETag layer takes over after 30 s | +| `/api/*` | bypass | Separate behavior to customer ALB (Option A: not via CloudFront at all) | + +30 s on the shell is the sweet spot: long enough to dedupe a thousand concurrent reloads +to one origin fetch, short enough that a promotion lands at all customers within ~90 s +end-to-end (60 s Function propagation + 30 s residual edge cache). + +## Per-customer branding (without per-customer bundles) + +If a customer needs a different logo or accent colour, the right move is to keep one +universal bundle and overlay branding assets at request time: + +- Universal default: `/release/v1.12.0/images/logo.png` in S3. +- Per-customer override (optional, only when needed): the Function checks for + `s3://collate-cdn/customer-overrides//logo.png` first and rewrites if it exists. + +Branding stays out of the build artifact, which means one bundle still serves every +customer and the cache-sharing argument holds. + +## Verification after promotion + +Two synthetic checks worth running automatically after a promotion PR merges: + +```bash +SLUG=acme +EXPECTED_VERSION=v1.12.0 + +# 1. CloudFront serves the right release for this slug +RESPONSE=$(curl -s "https://${SLUG}.getcolate.io/?nocache=$(uuidgen)") +echo "$RESPONSE" | grep -oE 'index-[A-Za-z0-9_-]+\.js' | sort -u +# Should match the hash from the v1.12.0 build manifest + +# 2. The HTML shell is being served fresh from the right S3 prefix +curl -sI "https://${SLUG}.getcolate.io/" \ + | grep -i 'x-amz-cf-pop\|via\|x-cache' +# Should show an edge POP near the test runner, and either "Miss from cloudfront" +# (first request after promotion) or "Hit from cloudfront" (within the 30 s edge TTL) +``` + +CI runs this on every promotion PR after the Function deploys, and fails loud if the +served bundle doesn't match the version we just pinned. + +## What's not in this design + +- **Per-customer API origin selection inside CloudFront**. Option A keeps `/api/*` off + the CloudFront path entirely. If a customer ever needs single-host behavior, that's + the moment to revisit Option B and accept Lambda@Edge. +- **Multi-region S3 origin failover**. Single bucket in one region; CloudFront's edge + caching handles regional reach. If you want CRR + origin groups, add them; the cost + is straightforward but rarely justified for a UI bundle. +- **WAF / Shield Advanced**. Add separately if your security posture requires them. + +## What this design is good for and what would push it elsewhere + +- **Good for**: dozens to low-hundreds of customers, infrequent promotion (a few per + week), engineering ownership over the routing table. +- **Push toward KVS** when: customer count grows past a few hundred (function size + pressure) OR promotions happen via a non-engineering UI (a customer-success dashboard + that flips slugs without a git PR). +- **Push toward Lambda@Edge** when: routing decisions stop being a slug→version map and + start needing per-request information not available in the host header (e.g. A/B + testing by user ID, geo-routing, header-derived feature flags). + +When those days come, the migration path from this design is small — the Function code +becomes a `kvs.get(slug)` instead of a hash lookup, and the rest of the architecture +(S3 layout, distribution behaviors, ALB routing) is identical. diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index efbca4987287..f21c5e163449 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -169,6 +169,18 @@ io.dropwizard dropwizard-assets + + + io.dropwizard + dropwizard-http2 + io.dropwizard dropwizard-core diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ActivityStreamRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ActivityStreamRepository.java index c4f5b082fc8e..63a4c9fdd975 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ActivityStreamRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ActivityStreamRepository.java @@ -333,6 +333,24 @@ public int count(List domainIds, long afterTimestamp) { return activityStreamDAO.countByDomains(domainJson, domainIdStrings, afterTimestamp); } + /** Get count of activity events for a specific entity. */ + public int countByEntity(String entityType, UUID entityId, long afterTimestamp) { + return activityStreamDAO.countByEntity(entityType, entityId.toString(), afterTimestamp); + } + + /** Get count of activity events for a specific entity scoped to specific domains. */ + public int countByEntity( + String entityType, UUID entityId, List domainIds, long afterTimestamp) { + if (nullOrEmpty(domainIds)) { + return countByEntity(entityType, entityId, afterTimestamp); + } + + List domainIdStrings = domainIds.stream().map(UUID::toString).toList(); + String domainJson = JsonUtils.pojoToJson(domainIdStrings); + return activityStreamDAO.countByEntityAndDomains( + entityType, entityId.toString(), domainJson, domainIdStrings, afterTimestamp); + } + /** Delete events older than the cutoff timestamp. */ public int deleteOlderThan(long cutoffTimestamp) { return activityStreamDAO.deleteOlderThan(cutoffTimestamp); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index af4d16cb744d..b4bd7dad0190 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -13380,6 +13380,42 @@ int countByDomains( @BindList("domainIds") List domainIds, @Bind("after") long after); + @ConnectionAwareSqlQuery( + value = + "SELECT count(*) FROM activity_stream WHERE entityType = :entityType AND entityId = :entityId " + + "AND timestamp >= :after", + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = + "SELECT count(*) FROM activity_stream WHERE entitytype = :entityType AND entityid = :entityId " + + "AND timestamp >= :after", + connectionType = POSTGRES) + int countByEntity( + @Bind("entityType") String entityType, + @Bind("entityId") String entityId, + @Bind("after") long after); + + @ConnectionAwareSqlQuery( + value = + "SELECT count(*) FROM activity_stream WHERE entityType = :entityType AND entityId = :entityId " + + "AND JSON_OVERLAPS(domains, :domainJson) " + + "AND timestamp >= :after", + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = + "SELECT count(*) FROM activity_stream WHERE entitytype = :entityType AND entityid = :entityId " + + "AND EXISTS (" + + "SELECT 1 FROM jsonb_array_elements_text(domains) AS domain_id " + + "WHERE domain_id IN ()) " + + "AND timestamp >= :after", + connectionType = POSTGRES) + int countByEntityAndDomains( + @Bind("entityType") String entityType, + @Bind("entityId") String entityId, + @Bind("domainJson") String domainJson, + @BindList("domainIds") List domainIds, + @Bind("after") long after); + @SqlUpdate("DELETE FROM activity_stream WHERE timestamp < :cutoff") int deleteOlderThan(@Bind("cutoff") long cutoffTimestamp); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/activity/ActivityResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/activity/ActivityResource.java index 905902423340..f0f5fd3ebe9d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/activity/ActivityResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/activity/ActivityResource.java @@ -175,20 +175,27 @@ public ResultList getEntityActivityById( @Max(90) @QueryParam("days") int days, - @Parameter(description = "Maximum number of events to return") + @Parameter( + description = + "Maximum number of events to return. Pass 0 for a count-only response " + + "(empty data array, accurate paging.total).") @DefaultValue("50") - @Min(1) + @Min(0) @Max(200) @QueryParam("limit") int limit) { long afterTimestamp = Instant.now().minus(days, ChronoUnit.DAYS).toEpochMilli(); List domainIds = getEffectiveDomainsByFqn(securityContext, domain); + int total = + activityStreamRepository.countByEntity(entityType, entityId, domainIds, afterTimestamp); + if (limit == 0) { + return new ResultList<>(List.of(), null, null, total); + } List events = activityStreamRepository.listByEntity( entityType, entityId, domainIds, afterTimestamp, limit); - - return new ResultList<>(events, null, null, events.size()); + return new ResultList<>(events, null, null, total); } @GET @@ -219,9 +226,13 @@ public ResultList getEntityActivityByFqn( @Max(90) @QueryParam("days") int days, - @Parameter(description = "Maximum number of events to return") + @Parameter( + description = + "Maximum number of events to return. Pass 0 for a count-only response " + + "(empty data array, accurate paging.total). Frontend tab-badge fetches " + + "use this path so first paint isn't blocked on a 100-row list query.") @DefaultValue("50") - @Min(1) + @Min(0) @Max(200) @QueryParam("limit") int limit) { @@ -234,11 +245,15 @@ public ResultList getEntityActivityByFqn( UUID entityId = entity.getId(); List domainIds = getEffectiveDomainsByFqn(securityContext, domain); + int total = + activityStreamRepository.countByEntity(entityType, entityId, domainIds, afterTimestamp); + if (limit == 0) { + return new ResultList<>(List.of(), null, null, total); + } List events = activityStreamRepository.listByEntity( entityType, entityId, domainIds, afterTimestamp, limit); - - return new ResultList<>(events, null, null, events.size()); + return new ResultList<>(events, null, null, total); } @GET diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java index c6d0805fbca7..d5722c4044ca 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java @@ -16,6 +16,7 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerResponseContext; import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.Provider; import org.openmetadata.schema.EntityInterface; @@ -23,23 +24,79 @@ import org.openmetadata.service.util.EntityETag; /** - * JAX-RS filter that automatically adds ETag headers to GET responses - * containing EntityInterface entities. + * JAX-RS filter that adds an {@code ETag} header to entity GET responses and short-circuits to + * {@code 304 Not Modified} when the client's {@code If-None-Match} matches the computed ETag. + * + *

The 304 path saves the response body bytes on the wire and the client-side render cost on + * revisits — the server still computes the entity body (we'd need a cheap version-stamp lookup + * to truly skip the work, see design doc), but the network and client savings are immediate. + * + *

{@code Cache-Control: no-store} is emitted alongside the ETag. Without an explicit + * Cache-Control, Chrome falls back to heuristic caching for ETag-bearing responses and reuses + * the cached body on a 304. That breaks any mutation path where the server returns 304 with + * stale-relative-to-the-client state — notably the relationship-only mutations + * ({@code addFollower}, {@code removeFollower}, {@code updateVote}, + * {@code DataContractRepository.updateLatestResult}) that don't bump entity {@code version} or + * {@code updatedAt} and therefore leave the ETag unchanged. With {@code no-store} the browser + * never caches a body, so the only conditional-GET path is our explicit Axios interceptor, + * which already invalidates its cache on every mutation response. We keep emitting the ETag + * header so any future client (or our own interceptor) can opt in to conditional GETs. */ @Provider public class ETagResponseFilter implements ContainerResponseFilter { + private static final String CACHE_CONTROL_VALUE = "no-store"; + @Override public void filter( ContainerRequestContext requestContext, ContainerResponseContext responseContext) { try (var ignored = RequestLatencyContext.phase("etagGeneration")) { - if ("GET".equals(requestContext.getMethod()) - && responseContext.getStatus() == Response.Status.OK.getStatusCode() - && responseContext.getEntity() instanceof EntityInterface entity) { + if (!"GET".equals(requestContext.getMethod()) + || responseContext.getStatus() != Response.Status.OK.getStatusCode() + || !(responseContext.getEntity() instanceof EntityInterface entity)) { + return; + } + + String etag = EntityETag.generateETag(entity); + if (etag == null) { + return; + } + responseContext.getHeaders().putSingle(HttpHeaders.ETAG, etag); + responseContext.getHeaders().putSingle(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL_VALUE); + + String ifNoneMatch = requestContext.getHeaderString(HttpHeaders.IF_NONE_MATCH); + if (ifNoneMatch == null) { + return; + } + if (matchesAny(ifNoneMatch, etag)) { + // RFC 7232: 304 must NOT include a message body. Drop the entity so the + // serializer emits an empty body. Headers (including ETag) are preserved. + responseContext.setStatus(Response.Status.NOT_MODIFIED.getStatusCode()); + responseContext.setEntity(null); + } + } + } - String etag = EntityETag.generateETag(entity); - responseContext.getHeaders().add("ETag", etag); + /** + * RFC 7232 §3.2: {@code If-None-Match} can be {@code *} (match any), a single ETag, or a + * comma-separated list. Weak comparison is used — we treat {@code "abc"} and {@code W/"abc"} + * as matching, which is the spec's recommendation for cache-validation use. + */ + private static boolean matchesAny(String ifNoneMatch, String currentEtag) { + String trimmed = ifNoneMatch.trim(); + if ("*".equals(trimmed)) { + return true; + } + String currentBare = stripWeakPrefix(currentEtag); + for (String candidate : trimmed.split(",")) { + if (currentBare.equals(stripWeakPrefix(candidate.trim()))) { + return true; } } + return false; + } + + private static String stripWeakPrefix(String etag) { + return etag.startsWith("W/") ? etag.substring(2) : etag; } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/IndexResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/IndexResource.java index 8e936dcfe693..7839fb75e64c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/IndexResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/IndexResource.java @@ -12,6 +12,10 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.text.StringEscapeUtils; @@ -26,6 +30,15 @@ public class IndexResource { private static volatile String configProcessedHtml; private static volatile String configuredBasePath = "/"; + // ETag is computed from the body BEFORE the per-request cspNonce substitution and cached + // per-basePath (the basePath rarely changes within a process). The map keeps the ETag and + // the corresponding stable HTML together so a hit can answer 304 without re-rendering. + // Bounded to a handful of entries in practice — there's typically one configured basePath. + private static final ConcurrentHashMap ETAG_CACHE = + new ConcurrentHashMap<>(); + + private record EtagCacheEntry(String etag, String stableHtml) {} + public static void initialize(OpenMetadataApplicationConfig catalogConfig) { String rawIndexHtml; try (InputStream inputStream = IndexResource.class.getResourceAsStream("/assets/index.html")) { @@ -56,6 +69,9 @@ public static void initialize(OpenMetadataApplicationConfig catalogConfig) { .replace("${clusterName}", escapeJs(clusterName != null ? clusterName : "openmetadata")) .replace( "${appVersion}", escapeJs(new VersionResource().getCatalogVersion().getVersion())); + // Re-init may bake new values into the template — drop any cached ETags so the next + // request computes a fresh hash against the new body. + ETAG_CACHE.clear(); } private static String escapeJs(String value) { @@ -83,6 +99,51 @@ public static String getIndexFile(String basePath, String cspNonce) { return html; } + /** + * Strong ETag derived from the body before per-request {@code cspNonce} substitution. + * + *

The body is otherwise stable across requests in a running process — every dynamic value + * gets baked in at {@link #initialize}. So a SHA-1 of {@code getIndexFile(basePath)} uniquely + * identifies the deployed bundle's shell, and the same ETag will match between two requests + * unless the server was redeployed in between. + * + *

Callers must NOT 304 the response when a cspNonce is being substituted into the body — + * each request's nonce is different, so the cached body's stale nonce would be rejected by + * the CSP header. The asset servlet enforces this guard. + */ + public static String getIndexEtag(String basePath) { + String key = basePath == null ? "/" : basePath; + EtagCacheEntry cached = ETAG_CACHE.get(key); + String stableHtml = getIndexFile(basePath); + if (cached != null && cached.stableHtml.equals(stableHtml)) { + return cached.etag; + } + String etag = computeEtag(stableHtml); + ETAG_CACHE.put(key, new EtagCacheEntry(etag, stableHtml)); + return etag; + } + + private static String computeEtag(String body) { + try { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + byte[] digest = sha1.digest(body.getBytes(StandardCharsets.UTF_8)); + // Strong ETag format per RFC 7232. Base64url keeps the value short (~28 chars). + return "\"" + Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + "\""; + } catch (NoSuchAlgorithmException e) { + // SHA-1 is mandated by every JRE; reaching here means the platform is fundamentally + // broken. Surface immediately rather than degrade silently. + throw new IllegalStateException("SHA-1 not available", e); + } + } + + /** + * Drops cached ETag entries — used by tests and after {@link #initialize} so a re-init in + * the same process picks up the fresh template hash. + */ + static void clearEtagCacheForTesting() { + ETAG_CACHE.clear(); + } + @GET @Produces(MediaType.TEXT_HTML) public Response getIndex(@Context HttpServletRequest request) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java index ecb6775026e6..1a1c3ccf2aff 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/socket/OpenMetadataAssetServlet.java @@ -27,9 +27,11 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Set; +import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.Nullable; import org.openmetadata.service.config.OMWebConfiguration; +import org.openmetadata.service.config.web.CspHeaderFactory; import org.openmetadata.service.resources.system.IndexResource; import org.openmetadata.service.security.CspNonceHandler; @@ -40,6 +42,24 @@ public class OpenMetadataAssetServlet extends AssetServlet { "js", "css", "map", "json", "txt", "html", "ico", "png", "jpg", "jpeg", "svg", "gif", "webp", "woff", "woff2", "ttf", "eot", "otf", "pdf", "md"); + // Matches Vite's content-hash filename pattern, e.g. `index-Z3O_FBkA.js`, + // `MyComponent.component-a1b2c3d4.css`. The hash chunk is base64url and at + // least 8 chars — long enough to make accidental collisions vanishingly + // unlikely. Anything matching is safe to mark {@code immutable} because the + // filename changes whenever the content does. + private static final Pattern HASHED_ASSET = + Pattern.compile(".*-[A-Za-z0-9_-]{8,}\\.[a-z0-9]+(\\.br|\\.gz)?$"); + + private static final String IMMUTABLE_CACHE = "public, max-age=31536000, immutable"; + + // The HTML shell points at hash-named JS chunks, so it MUST be re-fetched + // (or revalidated) on every load — otherwise a fresh deploy lands but the + // browser keeps the stale shell that references chunks that no longer + // exist. {@code no-cache} forces revalidation on every load; together + // with the ETag emitted by {@link IndexResource} the request settles as + // a 304 with ~150 bytes when nothing changed. + private static final String REVALIDATE_CACHE = "no-cache, must-revalidate"; + private final OMWebConfiguration webConfiguration; private final String basePath; private final String resourcePath; @@ -60,13 +80,13 @@ public OpenMetadataAssetServlet( protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { setSecurityHeader(webConfiguration, resp); + applyCacheControl(req, resp); String requestUri = req.getRequestURI(); if (requestUri.endsWith("/")) { final String cspNonce = (String) req.getAttribute(CspNonceHandler.CSP_NONCE_ATTRIBUTE); - resp.setContentType("text/html"); - resp.getWriter().write(IndexResource.getIndexFile(this.basePath, cspNonce)); + writeIndexHtml(req, resp, cspNonce); return; } @@ -112,14 +132,106 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) if (isSpaRoute(requestUri)) { final String cspNonce = (String) req.getAttribute(CspNonceHandler.CSP_NONCE_ATTRIBUTE); resp.setStatus(200); - resp.setContentType("text/html"); - resp.getWriter().write(IndexResource.getIndexFile(this.basePath, cspNonce)); + writeIndexHtml(req, resp, cspNonce); } else { resp.sendError(404); } } } + /** + * Write the SPA shell, honouring {@code If-None-Match} with a 304 when possible. + * + *

The earlier draft of this method gated on whether {@link CspNonceHandler} had populated + * a {@code cspNonce} request attribute — but that handler always populates the attribute, + * even on deployments that never emit a CSP header. The effect was that the ETag path was + * never taken in practice. Now we gate on whether CSP is actually enforced in a way + * that depends on the per-request body content (i.e. the configured policy contains the + * {@code __CSP_NONCE__} placeholder). For everything else — no CSP at all, or a CSP that + * uses {@code 'self'} / hash-based directives — the nonce attribute in the body is + * decorative and serving a cached body with a stale nonce against a fresh CSP header + * cannot cause script execution to fail. + * + *

The ETag itself describes the stable shell (post-basePath substitution, pre-nonce). It + * changes when the running JAR's bundled {@code index.html} or {@code basePath} change — i.e. + * on every deploy — and stays constant within a process otherwise. + */ + private void writeIndexHtml(HttpServletRequest req, HttpServletResponse resp, String cspNonce) + throws IOException { + String etag = IndexResource.getIndexEtag(this.basePath); + if (!cspRequiresPerRequestBody()) { + resp.setHeader("ETag", etag); + String ifNoneMatch = req.getHeader("If-None-Match"); + if (etag.equals(ifNoneMatch)) { + resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + return; + } + } + resp.setContentType("text/html"); + resp.getWriter().write(IndexResource.getIndexFile(this.basePath, cspNonce)); + } + + /** + * True when the configured CSP policy contains the {@code __CSP_NONCE__} placeholder, which + * {@link CspNonceHandler} replaces with a fresh per-request value. In that mode the response + * body's inline scripts must match the header's nonce on every load, so we can't safely 304 + * (the cached body would carry a stale nonce). False on the default deployment (no CSP + * configured) and on deployments that use {@code 'self'} / hash-based policies. + */ + private boolean cspRequiresPerRequestBody() { + if (webConfiguration == null) { + return false; + } + CspHeaderFactory csp = webConfiguration.getCspHeaderFactory(); + if (csp == null) { + return false; + } + return csp.build().values().stream() + .anyMatch(v -> v != null && v.contains(CspNonceHandler.CSP_NONCE_PLACEHOLDER)); + } + + /** + * Pick a {@code Cache-Control} policy by path shape. + * + *

    + *
  • Hashed assets under {@code /assets/} — names are content-addressed by Vite + * (e.g. {@code index-Z3O_FBkA.js}). The filename changes whenever the body changes, so + * the browser can cache forever and not even ask the server again. Emit + * {@code public, max-age=31536000, immutable}. + *
  • SPA HTML / fallback routes — the shell that references the hashed asset names. + * Must NOT be long-cached, else a fresh deploy lands and clients keep a stale shell + * pointing at chunks that no longer exist. Emit {@code no-cache, must-revalidate} so + * the browser revalidates every load; {@link IndexResource} attaches an ETag so the + * revalidate settles as a tiny 304 when nothing changed. + *
  • Unhashed static files (e.g. {@code favicon.ico}, {@code manifest.json}) — fall + * through with no explicit Cache-Control so the browser's heuristic kicks in. Adding a + * short {@code max-age} here is possible but low-ROI; revisit if logs show high + * refetch rates. + *
+ */ + private void applyCacheControl(HttpServletRequest req, HttpServletResponse resp) { + String requestUri = req.getRequestURI(); + String pathToCheck = stripBasePath(requestUri); + if (pathToCheck.startsWith("/assets/") && HASHED_ASSET.matcher(pathToCheck).matches()) { + resp.setHeader("Cache-Control", IMMUTABLE_CACHE); + return; + } + if (requestUri.endsWith("/") || requestUri.endsWith(".html") || isSpaRoute(requestUri)) { + resp.setHeader("Cache-Control", REVALIDATE_CACHE); + } + } + + private String stripBasePath(String requestUri) { + String normalizedBasePath = + basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath; + if (!"/".equals(normalizedBasePath) + && !normalizedBasePath.isEmpty() + && requestUri.startsWith(normalizedBasePath)) { + return requestUri.substring(normalizedBasePath.length()); + } + return requestUri; + } + /** * Check if the Accept-Encoding header supports the given encoding with non-zero quality value. * Handles q-values properly (e.g., "br;q=0" means encoding is explicitly disabled). diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/IndexResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/IndexResourceTest.java index 4d66ac48ec74..f4e09ca7d0af 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/IndexResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/IndexResourceTest.java @@ -164,4 +164,34 @@ void testCachedHtmlPerformance() { assertNotNull(html1); assertFalse(html1.isEmpty(), "HTML should not be empty"); } + + @Test + void testEtagIsStableAcrossCalls() { + // The ETag describes the stable shell — it must not change across two calls for the same + // basePath unless something has actually re-initialized the template. + String etagA = IndexResource.getIndexEtag("/"); + String etagB = IndexResource.getIndexEtag("/"); + assertEquals(etagA, etagB); + } + + @Test + void testEtagFormatIsStrongQuoted() { + // Strong ETag per RFC 7232: bare double-quoted token, no W/ prefix. + String etag = IndexResource.getIndexEtag("/"); + assertNotNull(etag); + assertTrue(etag.startsWith("\""), "ETag should be quoted"); + assertTrue(etag.endsWith("\""), "ETag should be quoted"); + assertFalse(etag.startsWith("W/"), "ETag should be a strong (non-weak) validator"); + } + + @Test + void testEtagDiffersAcrossBasePaths() { + // The body bakes the basePath into multiple positions (window.BASE_PATH, favicon hrefs, + // etc.), so the ETag for two different basePaths must differ. + String etagRoot = IndexResource.getIndexEtag("/"); + String etagCustom = IndexResource.getIndexEtag("/openmetadata/"); + assertFalse( + etagRoot.equals(etagCustom), + "ETag for two different basePaths must differ — they produce different bodies"); + } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java index 07cd1e220878..5bdd1409f196 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/socket/OpenMetadataAssetServletTest.java @@ -8,11 +8,15 @@ import jakarta.servlet.ServletOutputStream; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.PrintWriter; +import java.io.StringWriter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.MockitoAnnotations; import org.openmetadata.service.config.OMWebConfiguration; +import org.openmetadata.service.resources.system.IndexResource; public class OpenMetadataAssetServletTest { @@ -183,6 +187,181 @@ public void testFallbackToGzipIfBrotliMissing() throws Exception { verify(response).setContentType("application/javascript"); } + @Test + public void testHashedAssetGetsImmutableCacheControl() throws Exception { + String path = "/assets/index-Z3O_FBkA.js"; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getPathInfo()).thenReturn(path); + when(request.getServletPath()).thenReturn(""); + when(request.getHeader("Accept-Encoding")).thenReturn(null); + when(request.getMethod()).thenReturn("GET"); + when(request.getDateHeader(anyString())).thenReturn(-1L); + when(request.getHeader("If-None-Match")).thenReturn(null); + when(request.getHeader("If-Modified-Since")).thenReturn(null); + + servlet.doGet(request, response); + + // Hashed filenames are content-addressed, so they're safe to cache forever. + verify(response).setHeader("Cache-Control", "public, max-age=31536000, immutable"); + } + + @Test + public void testUnhashedAssetDoesNotGetImmutableCacheControl() throws Exception { + // {@code manifest.json} ships under {@code /assets/} without a content hash, + // so the immutable header would be wrong (a future deploy could change the file + // body while the URL stays the same). + String path = "/assets/manifest.json"; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getPathInfo()).thenReturn(path); + when(request.getServletPath()).thenReturn(""); + when(request.getHeader("Accept-Encoding")).thenReturn(null); + when(request.getMethod()).thenReturn("GET"); + when(request.getDateHeader(anyString())).thenReturn(-1L); + when(request.getHeader("If-None-Match")).thenReturn(null); + when(request.getHeader("If-Modified-Since")).thenReturn(null); + + servlet.doGet(request, response); + + verify(response, never()) + .setHeader(eq("Cache-Control"), eq("public, max-age=31536000, immutable")); + } + + @Test + public void testSpaRouteGetsRevalidateCacheControl() throws Exception { + // SPA routes (e.g. /table/foo.bar) serve the index.html shell, which must NOT + // be long-cached or clients keep the stale shell pointing at chunks that no + // longer exist after a deploy. + String path = "/table/service.db.schema.table"; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getPathInfo()).thenReturn(path); + when(request.getServletPath()).thenReturn(""); + when(request.getHeader("Accept-Encoding")).thenReturn(null); + when(request.getMethod()).thenReturn("GET"); + when(request.getDateHeader(anyString())).thenReturn(-1L); + when(request.getHeader("If-None-Match")).thenReturn(null); + when(request.getHeader("If-Modified-Since")).thenReturn(null); + + servlet.doGet(request, response); + + verify(response).setHeader("Cache-Control", "no-cache, must-revalidate"); + } + + @Test + public void testRootPathEmits304WhenIfNoneMatchMatches() throws Exception { + // When the client sends If-None-Match equal to the current shell's ETag AND no per-request + // CSP nonce is in play, the server short-circuits with 304 and writes no body. This is the + // dominant code path on a tab reload — saves the ~5 KB HTML download every time. + String path = "/"; + String etag = "\"abcDEF123456\""; + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getAttribute("cspNonce")).thenReturn(null); + when(request.getHeader("If-None-Match")).thenReturn(etag); + + try (MockedStatic indexResource = + org.mockito.Mockito.mockStatic(IndexResource.class)) { + indexResource.when(() -> IndexResource.getIndexEtag("/")).thenReturn(etag); + servlet.doGet(request, response); + } + + verify(response).setHeader("ETag", etag); + verify(response).setStatus(HttpServletResponse.SC_NOT_MODIFIED); + // Body must NOT be written when answering 304 — that's what makes the response cheap. + verify(response, never()).getWriter(); + } + + @Test + public void testRootPathEmits200AndBodyWhenIfNoneMatchDiffers() throws Exception { + String path = "/"; + String currentEtag = "\"currentETag\""; + String staleEtag = "\"staleClientETag\""; + StringWriter bodyCapture = new StringWriter(); + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getAttribute("cspNonce")).thenReturn(null); + when(request.getHeader("If-None-Match")).thenReturn(staleEtag); + when(response.getWriter()).thenReturn(new PrintWriter(bodyCapture)); + + try (MockedStatic indexResource = + org.mockito.Mockito.mockStatic(IndexResource.class)) { + indexResource.when(() -> IndexResource.getIndexEtag("/")).thenReturn(currentEtag); + indexResource + .when(() -> IndexResource.getIndexFile("/", null)) + .thenReturn("fresh"); + servlet.doGet(request, response); + } + + verify(response).setHeader("ETag", currentEtag); + verify(response, never()).setStatus(HttpServletResponse.SC_NOT_MODIFIED); + assertTrue(bodyCapture.toString().contains("fresh")); + } + + @Test + public void testRootPathSkipsEtagWhenCspPolicyUsesNonce() throws Exception { + // When the configured CSP policy contains __CSP_NONCE__ — meaning every request emits a + // unique CSP header that the response body's inline scripts must match — the servlet must + // not emit an ETag or attempt a 304. A cached body would carry a stale nonce that the + // next request's CSP header would reject. + String path = "/"; + String nonce = "request-nonce-abc"; + StringWriter bodyCapture = new StringWriter(); + + // Wire a CSP factory whose policy uses the __CSP_NONCE__ placeholder. The servlet's + // cspRequiresPerRequestBody() calls build() on the factory; build() returns an empty + // map unless {@code enabled} is true, so both flags need setting. + org.openmetadata.service.config.web.CspHeaderFactory cspFactory = + new org.openmetadata.service.config.web.CspHeaderFactory(); + cspFactory.setEnabled(true); + cspFactory.setPolicy("script-src 'nonce-__CSP_NONCE__'"); + when(webConfiguration.getCspHeaderFactory()).thenReturn(cspFactory); + + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getAttribute("cspNonce")).thenReturn(nonce); + when(request.getHeader("If-None-Match")).thenReturn("\"anything\""); + when(response.getWriter()).thenReturn(new PrintWriter(bodyCapture)); + + try (MockedStatic indexResource = + org.mockito.Mockito.mockStatic(IndexResource.class)) { + indexResource + .when(() -> IndexResource.getIndexFile("/", nonce)) + .thenReturn("nonce=" + nonce + ""); + servlet.doGet(request, response); + } + + verify(response, never()).setHeader(eq("ETag"), anyString()); + verify(response, never()).setStatus(HttpServletResponse.SC_NOT_MODIFIED); + assertTrue(bodyCapture.toString().contains(nonce)); + } + + @Test + public void testRootPathEmitsEtagWhenCspIsNotConfigured() throws Exception { + // The common case: no CSP header is configured. CspNonceHandler still populates the + // request attribute with a fresh value (it runs unconditionally) but the nonce is + // decorative — no CSP header polices the body's inline scripts. The servlet must emit + // ETag and honour 304 here; this is the test that would have caught the original bug. + String path = "/"; + String nonce = "request-nonce-abc"; + String etag = "\"shellEtag\""; + when(webConfiguration.getCspHeaderFactory()).thenReturn(null); + when(request.getRequestURI()).thenReturn(path); + when(request.getContextPath()).thenReturn(""); + when(request.getAttribute("cspNonce")).thenReturn(nonce); + when(request.getHeader("If-None-Match")).thenReturn(etag); + + try (MockedStatic indexResource = + org.mockito.Mockito.mockStatic(IndexResource.class)) { + indexResource.when(() -> IndexResource.getIndexEtag("/")).thenReturn(etag); + servlet.doGet(request, response); + } + + verify(response).setHeader("ETag", etag); + verify(response).setStatus(HttpServletResponse.SC_NOT_MODIFIED); + } + @Test public void testSpaRouteWithDotSeparatedEntityFqn() { assertTrue(servlet.isSpaRoute("/table/service.db.schema.table")); diff --git a/openmetadata-ui/src/main/resources/ui/index.html b/openmetadata-ui/src/main/resources/ui/index.html index cfded796e527..42ade890f14f 100644 --- a/openmetadata-ui/src/main/resources/ui/index.html +++ b/openmetadata-ui/src/main/resources/ui/index.html @@ -30,6 +30,27 @@ window.BASE_PATH = '${basePath}'; + + + - + OpenMetadata + + + -
+
+ +
/src/test/unit/mocks/reactColumnResize.mock.js', '^.*/Lineage/Layout/ELKUtil/ELKUtil$': '/src/test/unit/mocks/elkLayout.mock.js', + // Force every `require('react')` / `require('react-dom')` to resolve to the consumer's + // copy. The `openmetadata-ui-core-components` package has its own `node_modules/react` + // (for its own dev/test) — without these mappings the CJS bundle loaded from + // `dist/*.cjs.js` resolves React from the core-components tree, producing a second React + // instance with a null hooks dispatcher and the classic "Invalid hook call ... reading + // 'useContext'" TypeError. + '^react$': '/node_modules/react', + '^react-dom$': '/node_modules/react-dom', + '^react/(.*)$': '/node_modules/react/$1', + '^react-dom/(.*)$': '/node_modules/react-dom/$1', }, transformIgnorePatterns: [ 'node_modules/(?!(@azure/msal-react|react-dnd|react-dnd-html5-backend|dnd-core|@react-dnd/invariant|@react-dnd/asap|@react-dnd/shallowequal|@melloware/react-logviewer|@material/material-color-utilities|@openmetadata/ui-core-components|nanoid|@rjsf/core|@rjsf/utils|@rjsf/validator-ajv8|uuid|elkjs))', diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index 4c7b43bfac6a..e755b5ec3719 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -65,7 +65,7 @@ "@deuex-solutions/react-tour": "^1.2.6", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fontsource/inter": "^5.1.1", + "@fontsource-variable/inter": "^5.2.8", "@fontsource/poppins": "^5.0.0", "@fontsource/source-code-pro": "^5.0.0", "@github/g-emoji-element": "^1.1.5", @@ -85,6 +85,7 @@ "@rjsf/core": "5.24.13", "@rjsf/utils": "5.24.13", "@rjsf/validator-ajv8": "5.24.13", + "@tanstack/react-query": "^5.62.0", "@tiptap/core": "^2.3.0", "@tiptap/extension-link": "^2.10.4", "@tiptap/extension-placeholder": "^2.3.0", @@ -249,6 +250,7 @@ "postcss": "8.5.10", "prettier": "2.8.8", "react-test-renderer": "^18.2.0", + "rollup-plugin-visualizer": "^7.0.1", "sync-i18n": "^0.0.20", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", diff --git a/openmetadata-ui/src/main/resources/ui/public/app-worker.js b/openmetadata-ui/src/main/resources/ui/public/app-worker.js index e35285ec9391..66ae6cb9ae7f 100644 --- a/openmetadata-ui/src/main/resources/ui/public/app-worker.js +++ b/openmetadata-ui/src/main/resources/ui/public/app-worker.js @@ -15,6 +15,17 @@ const DB_NAME = 'AppDataStore'; const STORE_NAME = 'keyValueStore'; const DB_VERSION = 1; +// Asset cache for hashed /assets/* responses. Bumping ASSET_CACHE_VERSION on the next deploy +// is unnecessary — the cache keys are the full request URLs, which include the content hash +// in the filename (e.g. /assets/index-Z3O_FBkA.js). A new bundle ships under new filenames, +// so the cache effectively versions itself; the activate handler still prunes truly old +// caches in case the naming scheme ever changes. +const ASSET_CACHE = 'om-assets-v1'; +// Match Vite's content-hash filename pattern, e.g. `name-Z3O_FBkA.js`. The 8+ char hash chunk +// is base64url, which the bundler picks so collisions are vanishingly unlikely. Anything +// matching is safe to cache forever — the filename changes whenever the body changes. +const HASHED_ASSET_RE = /\/assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/; + const swStore = {}; // Pre-load data from IndexedDB when service worker starts @@ -98,11 +109,59 @@ self.addEventListener('install', (event) => { }); self.addEventListener('activate', (event) => { - // Claim control immediately after activation + // Claim control immediately after activation; in the same task, drop any old asset cache + // versions (in case the cache name scheme changes in a future release). event.waitUntil( - self.clients.claim().then(() => { - // Initialize the store to ensure it's ready for use - return initializeSwStore(); + Promise.all([ + self.clients.claim(), + caches + .keys() + .then((names) => + Promise.all( + names + .filter((name) => name.startsWith('om-assets-') && name !== ASSET_CACHE) + .map((name) => caches.delete(name)) + ) + ), + initializeSwStore(), + ]) + ); +}); + +// Cache-first for hashed /assets/* GETs. The browser's own HTTP cache (driven by the +// `Cache-Control: immutable` header the server emits for these paths) does the same job; +// the SW adds a second layer that survives browser-cache eviction under memory pressure and +// across tab/session lifecycles. Cost: ~1 KB of code, no impact when the browser HTTP cache +// already has the entry. +// +// Everything else — /api/*, the SPA HTML shell, unhashed paths — falls through to the +// network so revalidation/ETag/auth all keep working as written. +self.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') { + return; + } + const url = new URL(request.url); + if (url.origin !== self.location.origin) { + return; + } + if (!HASHED_ASSET_RE.test(url.pathname)) { + return; + } + event.respondWith( + caches.open(ASSET_CACHE).then(async (cache) => { + const cached = await cache.match(request); + if (cached) { + return cached; + } + const response = await fetch(request); + // Only cache successful, fully-typed responses. {@code response.ok} is false on 4xx/5xx, + // {@code response.type === 'basic'} excludes opaque cross-origin responses (we already + // gated on same-origin above but belt-and-braces). + if (response.ok && response.type === 'basic') { + cache.put(request, response.clone()).catch(() => undefined); + } + return response; }) ); }); diff --git a/openmetadata-ui/src/main/resources/ui/sonar-project.properties b/openmetadata-ui/src/main/resources/ui/sonar-project.properties index e83baba47f7d..885b9d4e2b20 100644 --- a/openmetadata-ui/src/main/resources/ui/sonar-project.properties +++ b/openmetadata-ui/src/main/resources/ui/sonar-project.properties @@ -7,7 +7,7 @@ sonar.language=ts # This property is optional if sonar.modules is set. sonar.sources=src sonar.tests=src/test/unit -sonar.exclusions=src/enums/**, src/generated/**, src/cypress/**, src/interface/**, src/jsons/**, src/mocks/**, src/styles/**, src/**/*.mock.*, src/*.js, src/**/*.test.ts, src/**/*.test.tsx, src/**/*.test.js, src/**/*.test.jsx +sonar.exclusions=src/enums/**, src/generated/**, src/cypress/**, src/interface/**, src/jsons/**, src/mocks/**, src/styles/**, src/test/**, src/**/*.mock.*, src/*.js, src/**/*.test.ts, src/**/*.test.tsx, src/**/*.test.js, src/**/*.test.jsx sonar.inclusions=src/**/*.ts, src/**/*.tsx, src/**/*.js, src/**/*.jsx sonar.typescript.lcov.reportPaths=src/test/unit/coverage/lcov.info sonar.testExecutionReportPaths=test-report.xml diff --git a/openmetadata-ui/src/main/resources/ui/src/App.tsx b/openmetadata-ui/src/main/resources/ui/src/App.tsx index 35f6d39c8ce1..c53bdceee35a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/App.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/App.tsx @@ -11,15 +11,33 @@ * limitations under the License. */ -import { FC } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { FC, useEffect } from 'react'; import AppRouter from './components/AppRouter/AppRouter'; import { AuthProvider } from './components/Auth/AuthProviders/AuthProvider'; +import { queryClient } from './queryClient'; +import { idlePrefetchRoutes } from './utils/idlePrefetchRoutes'; const App: FC = () => { + // After first paint, warm the chunk cache for Explore / Settings / EntityRouter + // during browser idle. Most users land on /my-data and click into Explore or an + // entity link next; pre-fetching those route chunks turns the click into a cache + // hit (~5ms) instead of a network round-trip (~200–500ms). + useEffect(() => { + idlePrefetchRoutes(); + }, []); + + // QueryClientProvider sits ABOVE AuthProvider so that the singleton is available everywhere + // — including AuthProvider's onLogout handler, which needs to clear the query cache so a + // freshly-authenticated user can't see another principal's cached entity bodies. The + // QueryClient itself is also exported from `./queryClient` for non-hook callers (axios + // interceptors, programmatic prefetch, etc.) that can't go through `useQueryClient()`. return ( - - - + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx index f050fb53925d..5fceb40b0bae 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx @@ -29,7 +29,11 @@ import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreApiEndPoint } from '../../../rest/apiEndpointsAPI'; import apiEndpointClassBase from '../../../utils/APIEndpoints/APIEndpointClassBase'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -179,6 +183,22 @@ const APIEndpointDetails: React.FC = ({ handleFeedCount ); + const fetchTaskCounts = useCallback(() => { + if (decodedApiEndpointFqn) { + fetchEntityTaskCountsInto(decodedApiEndpointFqn, setFeedCount); + } + }, [decodedApiEndpointFqn]); + + const fetchActivityCount = useCallback(() => { + if (decodedApiEndpointFqn) { + fetchEntityActivityCountInto( + EntityType.API_ENDPOINT, + decodedApiEndpointFqn, + setFeedCount + ); + } + }, [decodedApiEndpointFqn]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [navigate] @@ -209,7 +229,8 @@ const APIEndpointDetails: React.FC = ({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); }, [apiEndpointPermissions, decodedApiEndpointFqn]); const tabs = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppTour/AppTour.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppTour/AppTour.test.tsx index c423994e7087..ee604839d99a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppTour/AppTour.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppTour/AppTour.test.tsx @@ -66,7 +66,10 @@ describe('AppTour component', () => { it('element render and actions check', async () => { render(); - expect(screen.getByText('ReactTour')).toBeInTheDocument(); + // ReactTutorial is React.lazy()'d in Tour.tsx (the bundle-size PR's lazy + // tour work) so the Suspense child resolves on the next microtask. + // findBy* awaits that settle; the rest of the assertions follow. + expect(await screen.findByText('ReactTour')).toBeInTheDocument(); expect(screen.getByText('TourEndModal is close')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Close Request' })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppTour/Tour.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppTour/Tour.tsx index f900d776f782..adcde344aeaa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppTour/Tour.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppTour/Tour.tsx @@ -11,9 +11,9 @@ * limitations under the License. */ -import ReactTutorial, { TourSteps } from '@deuex-solutions/react-tour'; +import type { TourSteps } from '@deuex-solutions/react-tour'; import { Button } from 'antd'; -import { useState } from 'react'; +import { lazy, Suspense, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTourProvider } from '../../context/TourProvider/TourProvider'; import { CurrentTourPageType } from '../../enums/tour.enum'; @@ -21,6 +21,13 @@ import { useApplicationStore } from '../../hooks/useApplicationStore'; import TourEndModal from '../Modals/TourEndModal/TourEndModal'; import './tour.style.less'; +// `@deuex-solutions/react-tour` ships ~50 KB raw / ~14 KB brotli that only +// fires when a first-time user runs the in-app tour. Lazy-load so the chunk +// never lands in any user's bundle who isn't actively viewing the tour. +// Suspense fallback is `null` because the tour overlay would be the only +// visible content — a spinner here would just flash on first activation. +const ReactTutorial = lazy(() => import('@deuex-solutions/react-tour')); + const Tour = ({ steps }: { steps: TourSteps[] }) => { const { isTourOpen, updateIsTourOpen, updateTourPage } = useTourProvider(); const { theme } = useApplicationStore(); @@ -39,39 +46,41 @@ const Tour = ({ steps }: { steps: TourSteps[] }) => { return (
{isTourOpen ? ( - - - - } - type="text" - onClick={() => setShowTourEndModal(true)} - /> - } - maskColor="#302E36" - playTour={isTourOpen} - stepWaitTimer={900} - steps={steps} - onRequestClose={handleRequestClose} - onRequestSkip={handleModalSubmit} - /> + + + + + } + type="text" + onClick={() => setShowTourEndModal(true)} + /> + } + maskColor="#302E36" + playTour={isTourOpen} + stepWaitTimer={900} + steps={steps} + onRequestClose={handleRequestClose} + onRequestSkip={handleModalSubmit} + /> + ) : null} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx index 74ecf1275491..1332004d8294 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx @@ -53,7 +53,9 @@ import { AuthProvider as AuthProviderEnum } from '../../../generated/settings/se import { withDomainFilter } from '../../../hoc/withDomainFilter'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; +import { queryClient } from '../../../queryClient'; import axiosClient from '../../../rest'; +import { clearEtagCache } from '../../../rest/etagInterceptor'; import { fetchAuthenticationConfig, fetchAuthorizerConfig, @@ -218,6 +220,16 @@ export const AuthProvider = ({ // Clear tokens properly during logout await clearOidcToken(); + // Drop the ETag interceptor's response cache so a freshly-authenticated user can't + // pick up another principal's cached body via If-None-Match → 304 mid-session. + clearEtagCache(); + + // Same correctness story for the React Query cache — every cached entity / list response + // is keyed without the principal in the key (the request gets the principal from the + // Authorization header), so without an explicit clear the next user would see the + // previous user's cached bodies until staleTime + gcTime elapse. + queryClient.clear(); + setApplicationLoading(false); // Clear the refresh flag (used after refresh is complete) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx index 2b57fc629ad9..23eb7a2f765b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BulkEditEntity/BulkEditEntity.component.tsx @@ -13,8 +13,7 @@ import { Button, Col, Row } from 'antd'; import { isEmpty } from 'lodash'; import { useEffect, useMemo } from 'react'; -import DataGrid, { ColumnOrColumnGroup } from 'react-data-grid'; -import 'react-data-grid/lib/styles.css'; +import type { ColumnOrColumnGroup } from 'react-data-grid'; import { useTranslation } from 'react-i18next'; import { readString } from 'react-papaparse'; import { useNavigate } from 'react-router-dom'; @@ -28,6 +27,7 @@ import { } from '../../utils/EntityBulkEdit/EntityBulkEditUtils'; import { useRequiredParams } from '../../utils/useRequiredParams'; import Banner from '../common/Banner/Banner'; +import { LazyDataGrid } from '../common/DataGrid/LazyDataGrid'; import { ImportStatus } from '../common/EntityImport/ImportStatus/ImportStatus.component'; import Loader from '../common/Loader/Loader'; import TitleBreadcrumb from '../common/TitleBreadcrumb/TitleBreadcrumb.component'; @@ -108,7 +108,7 @@ const BulkEditEntity = ({ const editDataGrid = useMemo(() => { return (
- {validateCSVData && (
- ({ })); jest.mock('../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx index 06793797b27e..a45c954d9bf0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Chart/ChartDetails/ChartDetails.component.tsx @@ -31,7 +31,11 @@ import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreChart } from '../../../rest/chartsAPI'; import chartDetailsClassBase from '../../../utils/ChartDetailsClassBase'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -127,8 +131,25 @@ const ChartDetails = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.CHART, decodedChartFQN, handleFeedCount); + const fetchTaskCounts = useCallback(() => { + if (decodedChartFQN) { + fetchEntityTaskCountsInto(decodedChartFQN, setFeedCount); + } + }, [decodedChartFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedChartFQN) { + fetchEntityActivityCountInto( + EntityType.CHART, + decodedChartFQN, + setFeedCount + ); + } + }, [decodedChartFQN]); + useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); }, [decodedChartFQN]); const handleTabChange = (activeKey: string) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.test.tsx index 9fbf7cc61080..1f343833aa6d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.test.tsx @@ -95,6 +95,8 @@ jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ })); jest.mock('../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx index 34b8aeea04aa..d4ef6c14704e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx @@ -31,7 +31,11 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDashboard } from '../../../rest/dashboardAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -139,8 +143,25 @@ const DashboardDetails = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.DASHBOARD, decodedDashboardFQN, handleFeedCount); + const fetchTaskCounts = useCallback(() => { + if (decodedDashboardFQN) { + fetchEntityTaskCountsInto(decodedDashboardFQN, setFeedCount); + } + }, [decodedDashboardFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDashboardFQN) { + fetchEntityActivityCountInto( + EntityType.DASHBOARD, + decodedDashboardFQN, + setFeedCount + ); + } + }, [decodedDashboardFQN]); + useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); }, [decodedDashboardFQN]); const handleTabChange = (activeKey: string) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.test.tsx index 889521bbb1a2..b9589ef3fe73 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.test.tsx @@ -85,6 +85,8 @@ jest.mock('../../../../utils/useRequiredParams', () => ({ })); jest.mock('../../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx index fcc5293e36b9..3d0d85c6a11b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx @@ -27,7 +27,11 @@ import { useCustomPages } from '../../../../hooks/useCustomPages'; import { useFqn } from '../../../../hooks/useFqn'; import { FeedCounts } from '../../../../interface/feed.interface'; import { restoreDataModel } from '../../../../rest/dataModelsAPI'; -import { getFeedCounts } from '../../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -99,8 +103,27 @@ const DataModelDetails = ({ ); }; + const fetchTaskCounts = useCallback(() => { + if (decodedDataModelFQN) { + fetchEntityTaskCountsInto(decodedDataModelFQN, setFeedCount); + } + }, [decodedDataModelFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDataModelFQN) { + fetchEntityActivityCountInto( + EntityType.DASHBOARD_DATA_MODEL, + decodedDataModelFQN, + setFeedCount + ); + } + }, [decodedDataModelFQN]); + useEffect(() => { - decodedDataModelFQN && getEntityFeedCount(); + if (decodedDataModelFQN) { + fetchTaskCounts(); + fetchActivityCount(); + } }, [decodedDataModelFQN]); const handleUpdateDisplayName = async (data: EntityName) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.component.tsx index 6f0e2e86ec19..7883c228aa7d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.component.tsx @@ -12,7 +12,7 @@ */ import { Card, Typography } from 'antd'; import { useTranslation } from 'react-i18next'; -import { TooltipProps } from 'recharts'; +import type { TooltipProps } from 'recharts'; import { formatDateTimeLong } from '../../../utils/date-time/DateTimeUtils'; const ContractExecutionChartTooltip = ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.test.tsx index db31b064e426..28203ae88574 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractExecutionChart/ContractExecutionChartTooltip.test.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ import { render, screen } from '@testing-library/react'; -import { TooltipProps } from 'recharts'; +import type { TooltipProps } from 'recharts'; import { ContractExecutionStatus } from '../../../generated/type/contractExecutionStatus'; import ContractExecutionChartTooltip from './ContractExecutionChartTooltip.component'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/DailyActiveUsersChart.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/DailyActiveUsersChart.tsx index 19a5b4266f80..8ed549437ef4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/DailyActiveUsersChart.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/DailyActiveUsersChart.tsx @@ -38,11 +38,8 @@ import { DataInsightChartType } from '../../generated/dataInsight/dataInsightCha import { DailyActiveUsers } from '../../generated/dataInsight/type/dailyActiveUsers'; import { ChartFilter } from '../../interface/data-insight.interface'; import { getAggregateChartData } from '../../rest/DataInsightAPI'; -import { - CustomTooltip, - getFormattedActiveUsersData, - renderLegend, -} from '../../utils/DataInsightUtils'; +import { CustomTooltip, renderLegend } from '../../utils/DataInsightChartUtils'; +import { getFormattedActiveUsersData } from '../../utils/DataInsightUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import PageHeader from '../PageHeader/PageHeader.component'; import CustomStatistic from './CustomStatistic'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/DataInsightChartCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/DataInsightChartCard.tsx index d980dfa0df1a..8915bfeaa8f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/DataInsightChartCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/DataInsightChartCard.tsx @@ -49,10 +49,10 @@ import { } from '../../rest/DataInsightAPI'; import { updateActiveChartFilter } from '../../utils/ChartUtils'; import { entityChartColor } from '../../utils/CommonUtils'; +import { renderDataInsightLineChart } from '../../utils/DataInsightChartUtils'; import { getQueryFilterForDataInsightChart, isPercentageSystemGraph, - renderDataInsightLineChart, } from '../../utils/DataInsightUtils'; import { getExplorePath } from '../../utils/RouterUtils'; import searchClassBase from '../../utils/SearchClassBase'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/KPIChart.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/KPIChart.tsx index e7fc7f50b5fe..7e1a65976529 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/KPIChart.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/KPIChart.tsx @@ -54,7 +54,7 @@ import { import { DataInsightCustomChartResult } from '../../rest/DataInsightAPI'; import { getLatestKpiResult, getListKpiResult } from '../../rest/KpiAPI'; import { updateActiveChartFilter } from '../../utils/ChartUtils'; -import { CustomTooltip, renderLegend } from '../../utils/DataInsightUtils'; +import { CustomTooltip, renderLegend } from '../../utils/DataInsightChartUtils'; import { formatDate } from '../../utils/date-time/DateTimeUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import ErrorPlaceHolder from '../common/ErrorWithPlaceholder/ErrorPlaceHolder'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/PageViewsByEntitiesChart.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/PageViewsByEntitiesChart.tsx index 69d698f4accb..e768af64b392 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/PageViewsByEntitiesChart.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataInsight/PageViewsByEntitiesChart.tsx @@ -41,8 +41,8 @@ import { PageViewsByEntities } from '../../generated/dataInsight/type/pageViewsB import { ChartFilter } from '../../interface/data-insight.interface'; import { getAggregateChartData } from '../../rest/DataInsightAPI'; import { entityChartColor } from '../../utils/CommonUtils'; +import { CustomTooltip } from '../../utils/DataInsightChartUtils'; import { - CustomTooltip, getGraphDataByEntityType, sortEntityByValue, } from '../../utils/DataInsightUtils'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index 52ab46f02b9e..9f988cad5da2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -72,6 +72,8 @@ import { getContractByEntityId } from '../../../rest/contractAPI'; import { getDataProductPortsView } from '../../../rest/dataProductAPI'; import { searchQuery } from '../../../rest/searchAPI'; import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, getEntityDeleteMessage, getFeedCounts, hasEditAccess, @@ -198,6 +200,20 @@ const DataProductsDetailsPage = ({ ); }; + const fetchTaskCounts = useCallback(() => { + const fqn = dataProduct.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [dataProduct.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = dataProduct.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.DATA_PRODUCT, fqn, setFeedCount); + } + }, [dataProduct.fullyQualifiedName]); + const openAssetDrawer = useCallback(() => { setIsAssetDrawerOpen(true); }, []); @@ -666,7 +682,8 @@ const DataProductsDetailsPage = ({ useEffect(() => { fetchDataProductPermission(); fetchDataProductAssets(); - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); fetchActiveAnnouncement(); fetchDataProductContract(); fetchPortCounts(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.test.tsx index e994f9698a32..cd37fd9aae58 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.test.tsx @@ -11,9 +11,10 @@ * limitations under the License. */ -import { render, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { DataProduct } from '../../../generated/entity/domains/dataProduct'; +import { renderWithQueryClient } from '../../../test/unit/test-utils'; import PageLayoutV1 from '../../PageLayoutV1/PageLayoutV1'; import DataProductsPage from './DataProductsPage.component'; @@ -113,9 +114,11 @@ jest.mock('../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => { describe('DataProductsPage component', () => { it('should render successfully', async () => { - const { container } = render(, { - wrapper: MemoryRouter, - }); + const { container } = renderWithQueryClient( + + + + ); await waitFor(() => { expect(container).toBeInTheDocument(); @@ -123,9 +126,11 @@ describe('DataProductsPage component', () => { }); it('should pass entity name as pageTitle to PageLayoutV1', async () => { - render(, { - wrapper: MemoryRouter, - }); + renderWithQueryClient( + + + + ); await waitFor(() => { expect(PageLayoutV1).toHaveBeenCalledWith( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.tsx index 93d4bba22349..bdc9b42e7802 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsPage/DataProductsPage.component.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Button } from 'antd'; import { AxiosError } from 'axios'; import classNames from 'classnames'; @@ -20,7 +21,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { QueryVote } from '../../../components/Database/TableQueries/TableQueries.interface'; import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; -import { EntityType, TabSpecificField } from '../../../enums/entity.enum'; +import { EntityType } from '../../../enums/entity.enum'; import { DataProduct } from '../../../generated/entity/domains/dataProduct'; import { EntityHistory } from '../../../generated/type/entityHistory'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; @@ -36,6 +37,11 @@ import { removeFollower, updateDataProductVotes, } from '../../../rest/dataProductAPI'; +import { + dataProductQueryFn, + dataProductQueryKey, + DATA_PRODUCT_DEFAULT_FIELDS, +} from '../../../rest/queries/dataProductQuery'; import { getEntityName } from '../../../utils/EntityUtils'; import { getDomainPath, getVersionPath } from '../../../utils/RouterUtils'; import { getEncodedFqn } from '../../../utils/StringsUtils'; @@ -50,19 +56,56 @@ import DataProductsDetailsPage from '../DataProductsDetailsPage/DataProductsDeta const DataProductsPage = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { dataProductBasePath } = useMarketplaceStore(); const { version } = useRequiredParams<{ version: string }>(); const { currentUser } = useApplicationStore(); const currentUserId = currentUser?.id ?? ''; const { fqn: dataProductFqn } = useFqn(); - const [isMainContentLoading, setIsMainContentLoading] = useState(true); - const [dataProduct, setDataProduct] = useState(); const [versionList, setVersionList] = useState( {} as EntityHistory ); const [selectedVersionData, setSelectedVersionData] = useState(); const [isFollowingLoading, setIsFollowingLoading] = useState(false); + const dataProductCacheKey = useMemo( + () => dataProductQueryKey(dataProductFqn, DATA_PRODUCT_DEFAULT_FIELDS), + [dataProductFqn] + ); + + const { + data: dataProduct, + isLoading: dataProductLoading, + error: dataProductError, + } = useQuery({ + queryKey: dataProductCacheKey, + queryFn: dataProductQueryFn(dataProductFqn, DATA_PRODUCT_DEFAULT_FIELDS), + enabled: Boolean(dataProductFqn), + }); + + useEffect(() => { + const status = (dataProductError as AxiosError | undefined)?.response + ?.status; + if (dataProductError && status !== 404) { + showErrorToast(dataProductError as AxiosError); + } + }, [dataProductError]); + + const setDataProduct = useCallback( + ( + updater: + | DataProduct + | undefined + | ((prev: DataProduct | undefined) => DataProduct | undefined) + ) => { + queryClient.setQueryData( + dataProductCacheKey, + updater + ); + }, + [queryClient, dataProductCacheKey] + ); + const { isFollowing } = useMemo(() => { return { isFollowing: dataProduct?.followers?.some( @@ -117,63 +160,46 @@ const DataProductsPage = () => { } }; - const fetchDataProductByFqn = async (fqn: string) => { - setIsMainContentLoading(true); - try { - const data = await getDataProductByName(fqn, { - fields: [ - TabSpecificField.DOMAINS, - TabSpecificField.OWNERS, - TabSpecificField.EXPERTS, - TabSpecificField.ASSETS, - TabSpecificField.EXTENSION, - TabSpecificField.TAGS, - TabSpecificField.FOLLOWERS, - TabSpecificField.REVIEWERS, - TabSpecificField.VOTES, - TabSpecificField.CERTIFICATION, - ], - }); - setDataProduct(data); - - if (version) { - fetchVersionsInfo(data); - fetchActiveVersion(data); + const fetchVersionsInfo = useCallback( + async (activeDataProduct: DataProduct) => { + if (!activeDataProduct) { + return; } - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setIsMainContentLoading(false); - } - }; - const fetchVersionsInfo = async (activeDataProduct: DataProduct) => { - if (!activeDataProduct) { - return; - } + try { + const res = await getDataProductVersionsList(activeDataProduct.id); + setVersionList(res); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, + [] + ); - try { - const res = await getDataProductVersionsList(activeDataProduct.id); - setVersionList(res); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; + const fetchActiveVersion = useCallback( + async (activeDataProduct: DataProduct) => { + if (!activeDataProduct || !version) { + return; + } + try { + const res = await getDataProductVersionData( + activeDataProduct.id, + version + ); + setSelectedVersionData(res); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, + [version] + ); - const fetchActiveVersion = async (activeDataProduct: DataProduct) => { - if (!activeDataProduct) { - return; - } - try { - const res = await getDataProductVersionData( - activeDataProduct.id, - version - ); - setSelectedVersionData(res); - } catch (error) { - showErrorToast(error as AxiosError); + useEffect(() => { + if (dataProduct && version) { + fetchVersionsInfo(dataProduct); + fetchActiveVersion(dataProduct); } - }; + }, [dataProduct, version, fetchVersionsInfo, fetchActiveVersion]); const onVersionChange = (selectedVersion: string) => { const path = getVersionPath( @@ -188,91 +214,100 @@ const DataProductsPage = () => { navigate(`${dataProductBasePath}/${getEncodedFqn(dataProductFqn)}`); }; - const followDataProduct = async () => { - try { + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: DataProduct | undefined } + >({ + mutationFn: async () => { if (!dataProduct?.id) { return; } - const res = await addFollower(dataProduct.id, currentUserId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setDataProduct( - (prev) => - ({ - ...prev, - followers: [...(prev?.followers ?? []), ...newValue], - } as DataProduct) - ); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(dataProduct), - }) - ); - } - }; - - const unFollowDataProduct = async () => { - try { - if (!dataProduct?.id) { - return; + if (isFollowing) { + await removeFollower(dataProduct.id, currentUserId); + } else { + await addFollower(dataProduct.id, currentUserId); } - const res = await removeFollower(dataProduct.id, currentUserId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - - // Filter out the follower that was removed - const filteredFollowers = dataProduct.followers?.filter( - (follower) => follower.id !== oldValue[0].id + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: dataProductCacheKey }); + const previous = queryClient.getQueryData( + dataProductCacheKey ); + queryClient.setQueryData( + dataProductCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter( + ({ id }) => id !== currentUserId + ), + }; + } - setDataProduct( - (prev) => - ({ + return { ...prev, - followers: filteredFollowers ?? [], - } as DataProduct) + followers: [ + ...currentFollowers, + { id: currentUserId, type: 'user' }, + ] as DataProduct['followers'], + }; + } ); - } catch (error) { + + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + dataProductCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(dataProduct), - }) + isFollowing + ? t('server.entity-unfollow-error', { + entity: getEntityName(dataProduct), + }) + : t('server.entity-follow-error', { + entity: getEntityName(dataProduct), + }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: dataProductCacheKey }); + }, + }); const handleFollowingClick = useCallback(async () => { setIsFollowingLoading(true); - isFollowing ? await unFollowDataProduct() : await followDataProduct(); - setIsFollowingLoading(false); - }, [isFollowing, unFollowDataProduct, followDataProduct]); + try { + await followMutation.mutateAsync(); + } finally { + setIsFollowingLoading(false); + } + }, [followMutation]); - // Refresh data product without showing loader (for port updates) const refreshDataProduct = useCallback(async () => { if (!dataProductFqn) { return; } try { const data = await getDataProductByName(dataProductFqn, { - fields: [ - TabSpecificField.DOMAINS, - TabSpecificField.OWNERS, - TabSpecificField.EXPERTS, - TabSpecificField.ASSETS, - TabSpecificField.EXTENSION, - TabSpecificField.TAGS, - TabSpecificField.FOLLOWERS, - TabSpecificField.REVIEWERS, - TabSpecificField.VOTES, - TabSpecificField.CERTIFICATION, - ], + fields: DATA_PRODUCT_DEFAULT_FIELDS, }); setDataProduct(data); } catch (error) { showErrorToast(error as AxiosError); } - }, [dataProductFqn]); + }, [dataProductFqn, setDataProduct]); const handleUpdateVote = useCallback( async (data: QueryVote, id: string) => { @@ -286,13 +321,7 @@ const DataProductsPage = () => { [refreshDataProduct] ); - useEffect(() => { - if (dataProductFqn) { - fetchDataProductByFqn(dataProductFqn); - } - }, [dataProductFqn, version]); - - if (isMainContentLoading) { + if (dataProductLoading) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerDetailsCard/ProfilerDetailsCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerDetailsCard/ProfilerDetailsCard.tsx index 25a000c70567..4bb3fd8ee08f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerDetailsCard/ProfilerDetailsCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerDetailsCard/ProfilerDetailsCard.tsx @@ -34,7 +34,7 @@ import { tooltipFormatter, updateActiveChartFilter, } from '../../../../utils/ChartUtils'; -import { CustomDQTooltip } from '../../../../utils/DataQuality/DataQualityUtils'; +import { CustomDQTooltip } from '../../../../utils/DataQuality/CustomDQTooltip.component'; import { formatDateTimeLong } from '../../../../utils/date-time/DateTimeUtils'; import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { ProfilerDetailsCardProps } from '../ProfilerDashboard/profilerDashboard.interface'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.component.tsx index 257664d57f0f..2abfb874150e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/CustomMetricGraphs/CustomMetricGraphs.component.tsx @@ -45,7 +45,7 @@ import { createHorizontalGridLineRenderer, tooltipFormatter, } from '../../../../../utils/ChartUtils'; -import { CustomDQTooltip } from '../../../../../utils/DataQuality/DataQualityUtils'; +import { CustomDQTooltip } from '../../../../../utils/DataQuality/CustomDQTooltip.component'; import { formatDateTimeLong } from '../../../../../utils/date-time/DateTimeUtils'; import { getPrioritizedEditPermission } from '../../../../../utils/PermissionsUtils'; import { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx index bd57191436d6..4fa07314cb60 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/TableProfilerProvider.tsx @@ -28,7 +28,6 @@ import { ReactComponent as CreatedDateIcon } from '../../../../assets/svg/data-o import { ReactComponent as ProfileSampleIcon } from '../../../../assets/svg/data-observability/profile-sample.svg'; import { ReactComponent as RowCountIcon } from '../../../../assets/svg/data-observability/row-count.svg'; import { ReactComponent as TotalSizeIcon } from '../../../../assets/svg/data-observability/total-size.svg'; -import { mockDatasetData } from '../../../../constants/mockTourData.constants'; import { DEFAULT_SORT_ORDER } from '../../../../constants/profiler.constant'; import { useTourProvider } from '../../../../context/TourProvider/TourProvider'; import { TabSpecificField } from '../../../../enums/entity.enum'; @@ -298,7 +297,11 @@ export const TableProfilerProvider = ({ setIsProfilerDataLoading(false); } if (isTourOpen) { - setTableProfiler(mockDatasetData.tableDetails as unknown as Table); + import('../../../../constants/mockTourData.constants').then( + ({ mockDatasetData }) => { + setTableProfiler(mockDatasetData.tableDetails as unknown as Table); + } + ); } }, [datasetFQN, isTourOpen, activeTab]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleDataTable.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleDataTable.component.tsx index ed5fa3fdfbce..3d48693706dd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleDataTable.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/SampleDataTable/SampleDataTable.component.tsx @@ -23,7 +23,6 @@ import { ReactComponent as IconDelete } from '../../../assets/svg/ic-delete.svg' import { ReactComponent as IconDownload } from '../../../assets/svg/ic-download.svg'; import { ReactComponent as IconDropdown } from '../../../assets/svg/menu.svg'; import { AUTO_CLASSIFICATION_DOCS } from '../../../constants/docs.constants'; -import { mockDatasetData } from '../../../constants/mockTourData.constants'; import { useTourProvider } from '../../../context/TourProvider/TourProvider'; import { EntityType } from '../../../enums/entity.enum'; import { Container } from '../../../generated/entity/data/container'; @@ -245,11 +244,15 @@ const SampleDataTable: FC = ({ setIsLoading(false); } if (isTourPage) { - setSampleData( - getSampleDataWithType({ - columns: mockDatasetData.tableDetails.columns, - sampleData: mockDatasetData.sampleData, - } as unknown as Table) + import('../../../constants/mockTourData.constants').then( + ({ mockDatasetData }) => { + setSampleData( + getSampleDataWithType({ + columns: mockDatasetData.tableDetails.columns, + sampleData: mockDatasetData.sampleData, + } as unknown as Table) + ); + } ); } }, [tableId]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.component.tsx index 6f70d1342cb1..337edd60b531 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -19,7 +20,6 @@ import { useNavigate } from 'react-router-dom'; import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; import { ResourceEntity } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { ERROR_PLACEHOLDER_TYPE, SIZE } from '../../../enums/common.enum'; -import { TabSpecificField } from '../../../enums/entity.enum'; import { Domain } from '../../../generated/entity/domains/domain'; import { Operation } from '../../../generated/entity/policies/policy'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; @@ -27,11 +27,16 @@ import { useFqn } from '../../../hooks/useFqn'; import { useMarketplaceStore } from '../../../hooks/useMarketplaceStore'; import { addFollower, - getDomainByName, patchDomains, removeFollower, updateDomainVotes, } from '../../../rest/domainAPI'; +import { + domainQueryFn, + domainQueryKey, + DOMAIN_DEFAULT_FIELDS, +} from '../../../rest/queries/domainQuery'; +import { getEntityMissingError } from '../../../utils/CommonUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import { checkPermission } from '../../../utils/PermissionsUtils'; import { getDomainPath } from '../../../utils/RouterUtils'; @@ -47,14 +52,65 @@ const DomainDetailPage = () => { const { fqn: domainFqn } = useFqn(); const { t } = useTranslation(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { domainBasePath } = useMarketplaceStore(); const { currentUser } = useApplicationStore(); const currentUserId = currentUser?.id ?? ''; const { permissions } = usePermissionProvider(); - const [isMainContentLoading, setIsMainContentLoading] = useState(false); - const [activeDomain, setActiveDomain] = useState(); const [isFollowingLoading, setIsFollowingLoading] = useState(false); + const [viewBasicDomainPermission, viewAllDomainPermission] = useMemo(() => { + return [ + checkPermission(Operation.ViewBasic, ResourceEntity.DOMAIN, permissions), + checkPermission(Operation.ViewAll, ResourceEntity.DOMAIN, permissions), + ]; + }, [permissions]); + + const canViewDomain = viewBasicDomainPermission || viewAllDomainPermission; + + const domainCacheKey = useMemo( + () => domainQueryKey(domainFqn, DOMAIN_DEFAULT_FIELDS), + [domainFqn] + ); + + const { + data: activeDomain, + isLoading: domainLoading, + error: domainError, + } = useQuery({ + queryKey: domainCacheKey, + queryFn: domainQueryFn(domainFqn, DOMAIN_DEFAULT_FIELDS), + enabled: Boolean(domainFqn) && canViewDomain, + }); + + const isError = useMemo( + () => (domainError as AxiosError | undefined)?.response?.status === 404, + [domainError] + ); + + useEffect(() => { + if (domainError && !isError) { + showErrorToast( + domainError as AxiosError, + t('server.entity-fetch-error', { + entity: t('label.domain-lowercase'), + }) + ); + } + }, [domainError, isError, t]); + + const setActiveDomain = useCallback( + ( + updater: + | Domain + | undefined + | ((prev: Domain | undefined) => Domain | undefined) + ) => { + queryClient.setQueryData(domainCacheKey, updater); + }, + [queryClient, domainCacheKey] + ); + const { isFollowing } = useMemo(() => { return { isFollowing: activeDomain?.followers?.some( @@ -63,13 +119,6 @@ const DomainDetailPage = () => { }; }, [activeDomain?.followers, currentUserId]); - const [viewBasicDomainPermission, viewAllDomainPermission] = useMemo(() => { - return [ - checkPermission(Operation.ViewBasic, ResourceEntity.DOMAIN, permissions), - checkPermission(Operation.ViewAll, ResourceEntity.DOMAIN, permissions), - ]; - }, [permissions]); - const handleDomainUpdate = async (updatedData: Domain) => { if (activeDomain) { const jsonPatch = compare(activeDomain, updatedData); @@ -93,132 +142,103 @@ const DomainDetailPage = () => { navigate(domainBasePath); }; - const fetchDomainByName = async (domainFqn: string) => { - setIsMainContentLoading(true); - try { - const data = await getDomainByName(domainFqn, { - fields: [ - TabSpecificField.CHILDREN, - TabSpecificField.OWNERS, - TabSpecificField.PARENT, - TabSpecificField.EXPERTS, - TabSpecificField.TAGS, - TabSpecificField.FOLLOWERS, - TabSpecificField.EXTENSION, - TabSpecificField.VOTES, - TabSpecificField.CERTIFICATION, - ], - }); - setActiveDomain(data); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-fetch-error', { - entity: t('label.domain-lowercase'), - }) - ); - } finally { - setIsMainContentLoading(false); - } - }; - - const followDomain = async () => { - try { + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Domain | undefined } + >({ + mutationFn: async () => { if (!activeDomain?.id) { return; } - const res = await addFollower(activeDomain.id, currentUserId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setActiveDomain( - (prev) => - ({ - ...prev, - followers: [...(prev?.followers ?? []), ...newValue], - } as Domain) - ); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(activeDomain), - }) - ); - } - }; - - const unFollowDomain = async () => { - try { - if (!activeDomain?.id) { - return; + if (isFollowing) { + await removeFollower(activeDomain.id, currentUserId); + } else { + await addFollower(activeDomain.id, currentUserId); } - const res = await removeFollower(activeDomain.id, currentUserId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - - const filteredFollowers = activeDomain.followers?.filter( - (follower) => follower.id !== oldValue[0].id + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: domainCacheKey }); + const previous = queryClient.getQueryData( + domainCacheKey ); - - setActiveDomain( - (prev) => - ({ + queryClient.setQueryData(domainCacheKey, (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { ...prev, - followers: filteredFollowers ?? [], - } as Domain) - ); - } catch (error) { + followers: currentFollowers.filter( + ({ id }) => id !== currentUserId + ), + }; + } + + return { + ...prev, + followers: [ + ...currentFollowers, + { id: currentUserId, type: 'user' }, + ] as Domain['followers'], + }; + }); + + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + domainCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(activeDomain), - }) + isFollowing + ? t('server.entity-unfollow-error', { + entity: getEntityName(activeDomain), + }) + : t('server.entity-follow-error', { + entity: getEntityName(activeDomain), + }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: domainCacheKey }); + }, + }); const handleFollowingClick = useCallback(async () => { setIsFollowingLoading(true); - isFollowing ? await unFollowDomain() : await followDomain(); - setIsFollowingLoading(false); - }, [isFollowing, unFollowDomain, followDomain]); + try { + await followMutation.mutateAsync(); + } finally { + setIsFollowingLoading(false); + } + }, [followMutation]); const handleUpdateVote = useCallback( async (data: QueryVote, id: string) => { try { await updateDomainVotes(id, data); - const response = await getDomainByName(domainFqn, { - fields: [ - TabSpecificField.CHILDREN, - TabSpecificField.OWNERS, - TabSpecificField.PARENT, - TabSpecificField.EXPERTS, - TabSpecificField.TAGS, - TabSpecificField.FOLLOWERS, - TabSpecificField.EXTENSION, - TabSpecificField.VOTES, - TabSpecificField.CERTIFICATION, - ], - }); - setActiveDomain(response); + await queryClient.invalidateQueries({ queryKey: domainCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } }, - [domainFqn] + [queryClient, domainCacheKey] ); - useEffect(() => { - if (domainFqn) { - fetchDomainByName(domainFqn); - } - }, [domainFqn]); - useEffect(() => { if (!domainFqn) { navigate(domainBasePath); } }, [domainFqn, navigate, domainBasePath]); - if (!(viewBasicDomainPermission || viewAllDomainPermission)) { + if (!canViewDomain) { return ( { ); } - if (isMainContentLoading) { + if (domainLoading) { return ; } + if (isError) { + return ( + + {getEntityMissingError('domain', domainFqn)} + + ); + } + if (!activeDomain) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.test.tsx index 8a97f23e0c4d..ec86925b9cd5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetailPage/DomainDetailPage.test.tsx @@ -11,11 +11,12 @@ * limitations under the License. */ -import { act, render } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { DOMAINS_LIST } from '../../../mocks/Domains.mock'; import { ENTITY_PERMISSIONS } from '../../../mocks/Permissions.mock'; import { getDomainByName } from '../../../rest/domainAPI'; +import { renderWithQueryClient } from '../../../test/unit/test-utils'; import PageLayoutV1 from '../../PageLayoutV1/PageLayoutV1'; import DomainDetailPage from './DomainDetailPage.component'; @@ -73,22 +74,22 @@ describe('DomainDetailPage', () => { }); it('should render domain detail page', async () => { - const { container } = render( + const { container } = renderWithQueryClient( ); - // Check that the component renders without throwing expect(container).toBeInTheDocument(); - // The component should eventually fetch and display domain data - expect(mockGetDomainByName).toHaveBeenCalledWith( - 'test-domain', - expect.objectContaining({ - fields: expect.arrayContaining(['children', 'owners', 'parent']), - }) - ); + await waitFor(() => { + expect(mockGetDomainByName).toHaveBeenCalledWith( + 'test-domain', + expect.objectContaining({ + fields: expect.arrayContaining(['children', 'owners', 'parent']), + }) + ); + }); }); it('should handle missing FQN gracefully', () => { @@ -96,7 +97,7 @@ describe('DomainDetailPage', () => { const useFqnModule = jest.requireMock('../../../hooks/useFqn'); useFqnModule.useFqn.mockReturnValueOnce({ fqn: undefined }); - const { container } = render( + const { container } = renderWithQueryClient( @@ -107,23 +108,23 @@ describe('DomainDetailPage', () => { }); it('should pass entity name as pageTitle to PageLayoutV1', async () => { - await act(async () => { - render( - - - - ); - }); + renderWithQueryClient( + + + + ); - // Wait for the domain to be fetched - expect(mockGetDomainByName).toHaveBeenCalled(); + await waitFor(() => { + expect(mockGetDomainByName).toHaveBeenCalled(); + }); - // Verify pageTitle is passed - expect(PageLayoutV1).toHaveBeenCalledWith( - expect.objectContaining({ - pageTitle: DOMAINS_LIST[0].name, - }), - expect.anything() - ); + await waitFor(() => { + expect(PageLayoutV1).toHaveBeenCalledWith( + expect.objectContaining({ + pageTitle: DOMAINS_LIST[0].name, + }), + expect.anything() + ); + }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx index 6482d50cf014..0038ed8e733e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Domain/DomainDetails/DomainDetails.component.tsx @@ -67,7 +67,11 @@ import { } from '../../../rest/dataProductAPI'; import { addDomains, patchDomains } from '../../../rest/domainAPI'; import { searchQuery } from '../../../rest/searchAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { createEntityWithCoverImage } from '../../../utils/CoverImageUploadUtils'; import { checkIfExpandViewSupported, @@ -356,6 +360,20 @@ const DomainDetails = ({ ); }; + const fetchTaskCounts = useCallback(() => { + const fqn = domain.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [domain.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = domain.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.DOMAIN, fqn, setFeedCount); + } + }, [domain.fullyQualifiedName]); + const handleDataProductSubmit = useCallback( async (data: DomainFormValues) => { const formData = transformDomainFormData( @@ -915,7 +933,8 @@ const DomainDetails = ({ fetchDomainPermission(); fetchDomainAssets(); fetchDataProducts(); - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); fetchActiveAnnouncement(); }, [domain.fullyQualifiedName]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.test.tsx index 0fa4e35c753f..e1367415ed10 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.test.tsx @@ -35,9 +35,16 @@ jest.mock('../../../hooks/useFqn'); jest.mock('../../../utils/useRequiredParams'); jest.mock('../../../rest/driveAPI'); const mockGetFeedCounts = jest.fn(); +const mockFetchEntityTaskCountsInto = jest.fn(); +const mockFetchEntityActivityCountInto = jest.fn(); jest.mock('../../../utils/CommonUtils', () => ({ ...jest.requireActual('../../../utils/CommonUtils'), + fetchEntityActivityCountInto: ( + ...args: [EntityType, string, (data: FeedCounts) => void] + ) => mockFetchEntityActivityCountInto(...args), + fetchEntityTaskCountsInto: (...args: [string, (data: FeedCounts) => void]) => + mockFetchEntityTaskCountsInto(...args), getEntityMissingError: jest.fn(), getFeedCounts: (...args: [EntityType, string, (data: FeedCounts) => void]) => mockGetFeedCounts(...args), @@ -318,11 +325,15 @@ describe('DirectoryDetails', () => { expect(screen.getByTestId('data-assets-header')).toBeInTheDocument(); }); - it('should call getFeedCounts on component mount', async () => { + it('should fetch feed counts on component mount', async () => { renderDirectoryDetails(); await waitFor(() => { - expect(mockGetFeedCounts).toHaveBeenCalledWith( + expect(mockFetchEntityTaskCountsInto).toHaveBeenCalledWith( + 'test-service.test-directory', + expect.any(Function) + ); + expect(mockFetchEntityActivityCountInto).toHaveBeenCalledWith( EntityType.DIRECTORY, 'test-service.test-directory', expect.any(Function) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx index 9d32bb90f390..a488675a9596 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Directory/DirectoryDetails.tsx @@ -38,7 +38,11 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -271,6 +275,22 @@ function DirectoryDetails({ const getEntityFeedCount = () => getFeedCounts(EntityType.DIRECTORY, decodedDirectoryFQN, handleFeedCount); + const fetchTaskCounts = useCallback(() => { + if (decodedDirectoryFQN) { + fetchEntityTaskCountsInto(decodedDirectoryFQN, setFeedCount); + } + }, [decodedDirectoryFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDirectoryFQN) { + fetchEntityActivityCountInto( + EntityType.DIRECTORY, + decodedDirectoryFQN, + setFeedCount + ); + } + }, [decodedDirectoryFQN]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -323,7 +343,8 @@ function DirectoryDetails({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); }, [directoryPermissions, decodedDirectoryFQN]); const tabs = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.test.tsx index ed424365f41a..a763aeced9fe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.test.tsx @@ -34,9 +34,15 @@ jest.mock('../../../hooks/useFqn'); jest.mock('../../../utils/useRequiredParams'); jest.mock('../../../rest/driveAPI'); const mockGetFeedCounts = jest.fn(); +const mockFetchEntityTaskCountsInto = jest.fn(); +const mockFetchEntityActivityCountInto = jest.fn(); jest.mock('../../../utils/CommonUtils', () => ({ ...jest.requireActual('../../../utils/CommonUtils'), + fetchEntityActivityCountInto: (...args: any[]) => + mockFetchEntityActivityCountInto(...args), + fetchEntityTaskCountsInto: (...args: any[]) => + mockFetchEntityTaskCountsInto(...args), getEntityMissingError: jest.fn(), getFeedCounts: (...args: any[]) => mockGetFeedCounts(...args), })); @@ -321,12 +327,16 @@ describe('FileDetails', () => { expect(screen.getByTestId('data-assets-header')).toBeInTheDocument(); }); - it('should call getFeedCounts on component mount', async () => { + it('should fetch feed counts on component mount', async () => { renderFileDetails(); await waitFor( () => { - expect(mockGetFeedCounts).toHaveBeenCalledWith( + expect(mockFetchEntityTaskCountsInto).toHaveBeenCalledWith( + 'test-service.test-file.txt', + expect.any(Function) + ); + expect(mockFetchEntityActivityCountInto).toHaveBeenCalledWith( EntityType.FILE, 'test-service.test-file.txt', expect.any(Function) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx index f32b12850c8f..131695634a8f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/File/FileDetails.tsx @@ -37,7 +37,11 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -245,6 +249,22 @@ function FileDetails({ const getEntityFeedCount = () => getFeedCounts(EntityType.FILE, decodedFileFQN, handleFeedCount); + const fetchTaskCounts = useCallback(() => { + if (decodedFileFQN) { + fetchEntityTaskCountsInto(decodedFileFQN, setFeedCount); + } + }, [decodedFileFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedFileFQN) { + fetchEntityActivityCountInto( + EntityType.FILE, + decodedFileFQN, + setFeedCount + ); + } + }, [decodedFileFQN]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -293,7 +313,8 @@ function FileDetails({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); }, [filePermissions, decodedFileFQN]); const tabs = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.test.tsx index d3fadd662be0..533ce8a4e5d5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.test.tsx @@ -22,7 +22,11 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { ENTITY_PERMISSIONS } from '../../../mocks/Permissions.mock'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { getEntityDetailsPath } from '../../../utils/RouterUtils'; import { useRequiredParams } from '../../../utils/useRequiredParams'; import PageLayoutV1 from '../../PageLayoutV1/PageLayoutV1'; @@ -121,6 +125,9 @@ const mockUseFqn = useFqn as jest.Mock; const mockUseRequiredParams = useRequiredParams as jest.Mock; const mockRestoreDriveAsset = restoreDriveAsset as jest.Mock; const mockGetFeedCounts = getFeedCounts as jest.Mock; +const mockFetchEntityTaskCountsInto = fetchEntityTaskCountsInto as jest.Mock; +const mockFetchEntityActivityCountInto = + fetchEntityActivityCountInto as jest.Mock; const mockGetEntityDetailsPath = getEntityDetailsPath as jest.Mock; const mockSpreadsheetDetails: Spreadsheet = { @@ -305,11 +312,15 @@ describe('SpreadsheetDetails', () => { expect(screen.getByTestId('data-assets-header')).toBeInTheDocument(); }); - it('should call getFeedCounts on component mount', async () => { + it('should fetch feed counts on component mount', async () => { renderSpreadsheetDetails(); await waitFor(() => { - expect(mockGetFeedCounts).toHaveBeenCalledWith( + expect(mockFetchEntityTaskCountsInto).toHaveBeenCalledWith( + 'test-service.test-spreadsheet', + expect.any(Function) + ); + expect(mockFetchEntityActivityCountInto).toHaveBeenCalledWith( EntityType.SPREADSHEET, 'test-service.test-spreadsheet', expect.any(Function) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx index 059e86405071..efd024f33ac8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Spreadsheet/SpreadsheetDetails.tsx @@ -37,7 +37,11 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -253,6 +257,22 @@ function SpreadsheetDetails({ handleFeedCount ); + const fetchTaskCounts = useCallback(() => { + if (decodedSpreadsheetFQN) { + fetchEntityTaskCountsInto(decodedSpreadsheetFQN, setFeedCount); + } + }, [decodedSpreadsheetFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedSpreadsheetFQN) { + fetchEntityActivityCountInto( + EntityType.SPREADSHEET, + decodedSpreadsheetFQN, + setFeedCount + ); + } + }, [decodedSpreadsheetFQN]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -305,7 +325,8 @@ function SpreadsheetDetails({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); }, [spreadsheetPermissions, decodedSpreadsheetFQN]); const tabs = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.test.tsx index 76e17d33f79f..2f74709e1fbb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.test.tsx @@ -34,9 +34,15 @@ jest.mock('../../../hooks/useFqn'); jest.mock('../../../utils/useRequiredParams'); jest.mock('../../../rest/driveAPI'); const mockGetFeedCounts = jest.fn(); +const mockFetchEntityTaskCountsInto = jest.fn(); +const mockFetchEntityActivityCountInto = jest.fn(); jest.mock('../../../utils/CommonUtils', () => ({ ...jest.requireActual('../../../utils/CommonUtils'), + fetchEntityActivityCountInto: (...args: any[]) => + mockFetchEntityActivityCountInto(...args), + fetchEntityTaskCountsInto: (...args: any[]) => + mockFetchEntityTaskCountsInto(...args), getEntityMissingError: jest.fn(), getFeedCounts: (...args: any[]) => mockGetFeedCounts(...args), })); @@ -333,12 +339,16 @@ describe('WorksheetDetails', () => { expect(screen.getByTestId('data-assets-header')).toBeInTheDocument(); }); - it('should call getFeedCounts on component mount', async () => { + it('should fetch feed counts on component mount', async () => { renderWorksheetDetails(); await waitFor( () => { - expect(mockGetFeedCounts).toHaveBeenCalledWith( + expect(mockFetchEntityTaskCountsInto).toHaveBeenCalledWith( + 'test-service.test-spreadsheet.test-worksheet', + expect.any(Function) + ); + expect(mockFetchEntityActivityCountInto).toHaveBeenCalledWith( EntityType.WORKSHEET, 'test-service.test-spreadsheet.test-worksheet', expect.any(Function) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx index 4623b5407a8b..3475adc980aa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DriveService/Worksheet/WorksheetDetails.tsx @@ -36,7 +36,11 @@ import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useCustomPages } from '../../../hooks/useCustomPages'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreDriveAsset } from '../../../rest/driveAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -253,6 +257,20 @@ function WorksheetDetails({ handleFeedCount ); + const fetchTaskCounts = useCallback(() => { + const fqn = worksheetDetails.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [worksheetDetails.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = worksheetDetails.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.WORKSHEET, fqn, setFeedCount); + } + }, [worksheetDetails.fullyQualifiedName]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -306,7 +324,8 @@ function WorksheetDetails({ useEffect(() => { if (worksheetDetails.fullyQualifiedName) { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); } }, [worksheetPermissions, worksheetDetails.fullyQualifiedName]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityVersionTimeLine/BulkImportVersionSummary/BulkImportVersionSummary.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityVersionTimeLine/BulkImportVersionSummary/BulkImportVersionSummary.component.tsx index 6f5ae5e3513b..b628095ae700 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityVersionTimeLine/BulkImportVersionSummary/BulkImportVersionSummary.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityVersionTimeLine/BulkImportVersionSummary/BulkImportVersionSummary.component.tsx @@ -19,12 +19,11 @@ import { } from '@openmetadata/ui-core-components'; import { capitalize, isUndefined } from 'lodash'; import { useState } from 'react'; -import DataGrid from 'react-data-grid'; -import 'react-data-grid/lib/styles.css'; import { useTranslation } from 'react-i18next'; import { usePapaParse } from 'react-papaparse'; import { CSVImportResult } from '../../../../generated/type/csvImportResult'; import { renderColumnDataEditor } from '../../../../utils/CSV/CSV.utils'; +import { LazyDataGrid } from '../../../common/DataGrid/LazyDataGrid'; interface BulkImportVersionSummaryProps { csvImportResult: CSVImportResult; @@ -161,7 +160,7 @@ export const BulkImportVersionSummary = ({ style={{ height: '60vh', maxHeight: 700 }}> {tableData && (
- ({ + prefetchTable: (...args: unknown[]) => mockPrefetchTable(...args), +})); + +jest.mock('../../../rest/queries/dashboardQuery', () => ({ + prefetchDashboard: (...args: unknown[]) => mockPrefetchDashboard(...args), +})); + +jest.mock('../../../rest/queries/pipelineQuery', () => ({ + prefetchPipeline: (...args: unknown[]) => mockPrefetchPipeline(...args), +})); + +jest.mock('../../../rest/queries/topicQuery', () => ({ + prefetchTopic: (...args: unknown[]) => mockPrefetchTopic(...args), +})); + jest.mock('../../../utils/RouterUtils', () => ({ getDomainPath: jest.fn().mockReturnValue('/mock-domain'), })); @@ -79,7 +101,7 @@ const defaultProps: Omit = { const renderCard = ( sourceOverrides: Partial ) => - render( + renderWithQueryClient( { }); it('uses base source when highlight is not provided', () => { - render( + renderWithQueryClient( { displayName: ['Test Table'], }; - render( + renderWithQueryClient( { name: ['test-table'], }; - render( + renderWithQueryClient( { ], }; - render( + renderWithQueryClient( { name: ['name'], }; - render( + renderWithQueryClient( { ], }; - render( + renderWithQueryClient( { it('handles empty highlight object', () => { const highlightData = {}; - render( + renderWithQueryClient( { }); it('memoizes source correctly when highlight changes', () => { - const { rerender } = render( + const { rerender } = renderWithQueryClient( { expect(highlightEntityNameAndDescription).toHaveBeenCalledTimes(2); }); }); + +describe('ExploreSearchCard - Prefetch on hover', () => { + beforeEach(() => { + mockPrefetchTable.mockClear(); + mockPrefetchDashboard.mockClear(); + mockPrefetchPipeline.mockClear(); + mockPrefetchTopic.mockClear(); + }); + + it.each<{ entityType: string; mockFn: jest.Mock; fqn: string }>([ + { + entityType: 'table', + mockFn: mockPrefetchTable, + fqn: 'svc.db.schema.users', + }, + { + entityType: 'dashboard', + mockFn: mockPrefetchDashboard, + fqn: 'svc.dash.daily-active', + }, + { + entityType: 'pipeline', + mockFn: mockPrefetchPipeline, + fqn: 'svc.pipe.etl', + }, + { + entityType: 'topic', + mockFn: mockPrefetchTopic, + fqn: 'svc.topic.events', + }, + ])( + 'prefetches details when hovering a $entityType card', + ({ entityType, mockFn, fqn }) => { + renderWithQueryClient( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('entity-link')); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(expect.anything(), fqn); + } + ); + + it('also prefetches on keyboard focus for accessibility', () => { + renderWithQueryClient( + + + + ); + + fireEvent.focus(screen.getByTestId('entity-link')); + + expect(mockPrefetchTable).toHaveBeenCalledTimes(1); + }); + + it('does not prefetch when entityType has no useQuery integration yet', () => { + renderWithQueryClient( + + + + ); + + fireEvent.mouseEnter(screen.getByTestId('entity-link')); + + expect(mockPrefetchTable).not.toHaveBeenCalled(); + expect(mockPrefetchDashboard).not.toHaveBeenCalled(); + expect(mockPrefetchPipeline).not.toHaveBeenCalled(); + expect(mockPrefetchTopic).not.toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx index 52337c86fff9..bb8174b1bb41 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx @@ -11,11 +11,12 @@ * limitations under the License. */ import Icon from '@ant-design/icons'; +import { useQueryClient } from '@tanstack/react-query'; import { Button, Checkbox, Col, Row, Space, Typography } from 'antd'; import classNames from 'classnames'; import { isEmpty, isObject, isString, startCase, uniqueId } from 'lodash'; import { ExtraInfo } from 'Models'; -import { forwardRef, useMemo } from 'react'; +import { forwardRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ReactComponent as ScoreIcon } from '../../../assets/svg/score.svg'; @@ -31,6 +32,10 @@ import { EntityReference } from '../../../generated/entity/type'; import { TagLabel } from '../../../generated/tests/testCase'; import { AssetCertification } from '../../../generated/type/assetCertification'; import { TableColumnSearchSource } from '../../../interface/search.interface'; +import { prefetchDashboard } from '../../../rest/queries/dashboardQuery'; +import { prefetchPipeline } from '../../../rest/queries/pipelineQuery'; +import { prefetchTable } from '../../../rest/queries/tableQuery'; +import { prefetchTopic } from '../../../rest/queries/topicQuery'; import { getEntityName, highlightEntityNameAndDescription, @@ -78,6 +83,7 @@ const ExploreSearchCard: React.FC = forwardRef< const { t } = useTranslation(); const { tab } = useRequiredParams<{ tab: string }>(); const { isTourOpen } = useTourProvider(); + const queryClient = useQueryClient(); const source = useMemo(() => { return highlight @@ -85,6 +91,38 @@ const ExploreSearchCard: React.FC = forwardRef< : _source; }, [_source, highlight]); + // Hover/focus on an entity card warms the React Query cache so the click that follows + // hits an already-populated slot. Dispatched on entityType because each detail page reads + // a slot keyed on its own {@code ['', fqn, fields]} convention; entity types that + // haven't migrated to useQuery yet fall through as no-ops. {@code prefetchQuery} is + // idempotent within the configured {@code staleTime}, so repeated hovers don't re-fire. + const handlePrefetch = useCallback(() => { + const fqn = source.fullyQualifiedName; + if (!fqn) { + return; + } + switch (source.entityType) { + case EntityType.TABLE: + prefetchTable(queryClient, fqn); + + break; + case EntityType.DASHBOARD: + prefetchDashboard(queryClient, fqn); + + break; + case EntityType.PIPELINE: + prefetchPipeline(queryClient, fqn); + + break; + case EntityType.TOPIC: + prefetchTopic(queryClient, fqn); + + break; + default: + break; + } + }, [queryClient, source.entityType, source.fullyQualifiedName]); + const otherDetails = useMemo(() => { if (source?.entityType === EntityType.TABLE_COLUMN) { const columnSource = source as TableColumnSearchSource; @@ -341,7 +379,9 @@ const ExploreSearchCard: React.FC = forwardRef< source, openEntityInNewPage )} - to={isObject(entityLink) ? entityLink.pathname : entityLink}> + to={isObject(entityLink) ? entityLink.pathname : entityLink} + onFocus={handlePrefetch} + onMouseEnter={handlePrefetch}> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx index 716aa17b65d6..de3b49e47b08 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryDetails/GlossaryDetails.component.tsx @@ -21,7 +21,11 @@ import { EntityTabs, EntityType } from '../../../enums/entity.enum'; import { PageType } from '../../../generated/system/ui/page'; import { useCustomPages } from '../../../hooks/useCustomPages'; import { FeedCounts } from '../../../interface/feed.interface'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -72,6 +76,20 @@ const GlossaryDetails = ({ ); }; + const fetchTaskCounts = useCallback(() => { + const fqn = glossary.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [glossary.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = glossary.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.GLOSSARY, fqn, setFeedCount); + } + }, [glossary.fullyQualifiedName]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( @@ -164,7 +182,8 @@ const GlossaryDetails = ({ ]); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); }, [glossary.fullyQualifiedName]); const isExpandViewSupported = useMemo( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx index a8467e5f4bd6..cf75304d6ed6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.component.tsx @@ -33,7 +33,11 @@ import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { MOCK_GLOSSARY_NO_PERMISSIONS } from '../../../mocks/Glossary.mock'; import { searchQuery } from '../../../rest/searchAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -126,6 +130,20 @@ const GlossaryTermsV1 = ({ ); }; + const fetchTaskCounts = useCallback(() => { + const fqn = glossaryTerm.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityTaskCountsInto(fqn, setFeedCount); + } + }, [glossaryTerm.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + const fqn = glossaryTerm.fullyQualifiedName ?? ''; + if (fqn) { + fetchEntityActivityCountInto(EntityType.GLOSSARY_TERM, fqn, setFeedCount); + } + }, [glossaryTerm.fullyQualifiedName]); + const fetchGlossaryTermAssets = async () => { if (glossaryTerm) { try { @@ -225,7 +243,8 @@ const GlossaryTermsV1 = ({ fetchGlossaryTermAssets(); }, 500); if (!isVersionView) { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); } }, [glossaryFqn, isVersionView]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx index 5eeafc0f0113..f1ed2fd2f8b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryTerms/GlossaryTermsV1.test.tsx @@ -222,9 +222,12 @@ describe('Test Glossary-term component', () => { spy.mockRestore(); }); - it('should call getFeedCounts on mount when not in version view', async () => { - const getFeedCountsSpy = jest - .spyOn(CommonUtils, 'getFeedCounts') + it('should fetch feed counts on mount when not in version view', async () => { + const fetchTaskCountsSpy = jest + .spyOn(CommonUtils, 'fetchEntityTaskCountsInto') + .mockImplementation(jest.fn()); + const fetchActivityCountSpy = jest + .spyOn(CommonUtils, 'fetchEntityActivityCountInto') .mockImplementation(jest.fn()); const useRequiredParamsMock = useRequiredParams as jest.Mock; useRequiredParamsMock.mockReturnValue({ @@ -236,14 +239,19 @@ describe('Test Glossary-term component', () => { await screen.findByTestId('glossary-term'); - expect(getFeedCountsSpy).toHaveBeenCalled(); + expect(fetchTaskCountsSpy).toHaveBeenCalled(); + expect(fetchActivityCountSpy).toHaveBeenCalled(); - getFeedCountsSpy.mockRestore(); + fetchTaskCountsSpy.mockRestore(); + fetchActivityCountSpy.mockRestore(); }); - it('should not call getFeedCounts when in version view', async () => { - const getFeedCountsSpy = jest - .spyOn(CommonUtils, 'getFeedCounts') + it('should not fetch feed counts when in version view', async () => { + const fetchTaskCountsSpy = jest + .spyOn(CommonUtils, 'fetchEntityTaskCountsInto') + .mockImplementation(jest.fn()); + const fetchActivityCountSpy = jest + .spyOn(CommonUtils, 'fetchEntityActivityCountInto') .mockImplementation(jest.fn()); const useRequiredParamsMock = useRequiredParams as jest.Mock; useRequiredParamsMock.mockReturnValue({ @@ -255,8 +263,10 @@ describe('Test Glossary-term component', () => { await screen.findByTestId('glossary-term'); - expect(getFeedCountsSpy).not.toHaveBeenCalled(); + expect(fetchTaskCountsSpy).not.toHaveBeenCalled(); + expect(fetchActivityCountSpy).not.toHaveBeenCalled(); - getFeedCountsSpy.mockRestore(); + fetchTaskCountsSpy.mockRestore(); + fetchActivityCountSpy.mockRestore(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx index a57fd57c2adc..67f37e2a36ea 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/KnowledgeCenter/KnowledgePageDetailComponent/KnowledgePageDetailComponent.tsx @@ -78,7 +78,11 @@ import { unFollowKnowledgePage, updateKnowledgePageVote, } from '../../../rest/knowledgeCenterAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import contextCenterClassBase from '../../../utils/ContextCenterClassBase'; import i18n from '../../../utils/i18next/LocalUtil'; import { @@ -527,6 +531,22 @@ const KnowledgePageDetailComponent: FC = ({ } }; + const fetchTaskCounts = useCallback(() => { + if (knowledgePage?.fullyQualifiedName) { + fetchEntityTaskCountsInto(knowledgePage.fullyQualifiedName, setFeedCount); + } + }, [knowledgePage?.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + if (knowledgePage?.fullyQualifiedName) { + fetchEntityActivityCountInto( + EntityType.KNOWLEDGE_PAGE, + knowledgePage.fullyQualifiedName, + setFeedCount + ); + } + }, [knowledgePage?.fullyQualifiedName]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate(contextCenterClassBase.getArticlePath(fqn, activeKey)); @@ -659,7 +679,8 @@ const KnowledgePageDetailComponent: FC = ({ useEffect(() => { if (knowledgePage?.fullyQualifiedName) { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); } }, [knowledgePage?.fullyQualifiedName]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.test.tsx index adbce892675e..96f63e926d84 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.test.tsx @@ -82,6 +82,8 @@ jest.mock('../../../utils/useRequiredParams', () => ({ })); jest.mock('../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx index 381195240d6d..95927dd5c423 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Metric/MetricDetails/MetricDetails.tsx @@ -30,7 +30,11 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreMetric } from '../../../rest/metricsAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -191,6 +195,22 @@ const MetricDetails: React.FC = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.METRIC, decodedMetricFqn, handleFeedCount); + const fetchTaskCounts = useCallback(() => { + if (decodedMetricFqn) { + fetchEntityTaskCountsInto(decodedMetricFqn, setFeedCount); + } + }, [decodedMetricFqn]); + + const fetchActivityCount = useCallback(() => { + if (decodedMetricFqn) { + fetchEntityActivityCountInto( + EntityType.METRIC, + decodedMetricFqn, + setFeedCount + ); + } + }, [decodedMetricFqn]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate(ROUTES.METRICS), [] @@ -231,7 +251,8 @@ const MetricDetails: React.FC = ({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); }, [metricPermissions, decodedMetricFqn]); const tabs = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx index 35e9b402fbbb..fe2acad25939 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx @@ -34,7 +34,11 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreMlmodel } from '../../../rest/mlModelAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -142,9 +146,26 @@ const MlModelDetail: FC = ({ const fetchEntityFeedCount = () => getFeedCounts(EntityType.MLMODEL, decodedMlModelFqn, handleFeedCount); + const fetchTaskCounts = useCallback(() => { + if (decodedMlModelFqn) { + fetchEntityTaskCountsInto(decodedMlModelFqn, setFeedCount); + } + }, [decodedMlModelFqn]); + + const fetchActivityCount = useCallback(() => { + if (decodedMlModelFqn) { + fetchEntityActivityCountInto( + EntityType.MLMODEL, + decodedMlModelFqn, + setFeedCount + ); + } + }, [decodedMlModelFqn]); + useEffect(() => { if (mlModelPermissions.ViewAll || mlModelPermissions.ViewBasic) { - fetchEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); } }, [mlModelPermissions, decodedMlModelFqn]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.interface.ts index 95da0d9c2052..821665b835a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.interface.ts @@ -11,6 +11,7 @@ * limitations under the License. */ import { Document } from '../../../../generated/entity/docStore/document'; +import { AnnouncementEntity } from '../../../../rest/announcementsAPI'; export interface CustomiseLandingPageHeaderProps { addedWidgetsList?: string[]; @@ -27,4 +28,12 @@ export interface CustomiseLandingPageHeaderProps { onHomePage?: boolean; overlappedContainer?: boolean; placeholderWidgetKey?: string; + /** + * When the parent already fetches global announcements (the landing page does, for the + * sidebar widget), pass them through here so the header skips its own duplicate fetch. + * Backwards-compatible: when omitted, the header keeps its own fetch for callers that + * mount it standalone (e.g. the customize-page preview and the header-theme picker). + */ + announcements?: AnnouncementEntity[]; + isAnnouncementLoading?: boolean; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.tsx index 5e6c1b5cbfb5..d5b39fe71404 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader.tsx @@ -62,6 +62,8 @@ const CustomiseLandingPageHeader = ({ onHomePage = false, overlappedContainer = false, placeholderWidgetKey, + announcements: announcementsFromParent, + isAnnouncementLoading: isAnnouncementLoadingFromParent, }: CustomiseLandingPageHeaderProps) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -70,8 +72,18 @@ const CustomiseLandingPageHeader = ({ useDomainStore(); const [showCustomiseHomeModal, setShowCustomiseHomeModal] = useState(false); const [isDomainDropdownOpen, setIsDomainDropdownOpen] = useState(false); - const [announcements, setAnnouncements] = useState([]); - const [isAnnouncementLoading, setIsAnnouncementLoading] = useState(true); + // Internal fallback state — only used when the parent doesn't pass announcements through. + // The landing page (MyDataPage) already fetches global announcements for the sidebar + // widget; passing them down here de-duplicates the {@code GET /announcements/active} call. + // Standalone callers (customize-page preview, header-theme picker) still hit the API. + const [internalAnnouncements, setInternalAnnouncements] = useState< + AnnouncementEntity[] + >([]); + const [internalIsAnnouncementLoading, setInternalIsAnnouncementLoading] = + useState(true); + const announcements = announcementsFromParent ?? internalAnnouncements; + const isAnnouncementLoading = + isAnnouncementLoadingFromParent ?? internalIsAnnouncementLoading; const [showAnnouncements, setShowAnnouncements] = useState(false); const adminPanelBackgroundColor = applicationConfig?.customTheme?.panelBackgroundColor; @@ -113,16 +125,16 @@ const CustomiseLandingPageHeader = ({ const fetchAnnouncements = useCallback(async () => { try { - setIsAnnouncementLoading(true); + setInternalIsAnnouncementLoading(true); const response = await getActiveAnnouncements(); - setAnnouncements(response.data); + setInternalAnnouncements(response.data); setShowAnnouncements(response.data.length > 0); } catch (error) { showErrorToast(error as AxiosError); setShowAnnouncements(false); } finally { - setIsAnnouncementLoading(false); + setInternalIsAnnouncementLoading(false); } }, []); @@ -160,8 +172,15 @@ const CustomiseLandingPageHeader = ({ }; useEffect(() => { + // Skip the duplicate fetch when the parent already provided announcements. Keep showing + // them when non-empty, mirroring what the internal fetch path does. + if (announcementsFromParent !== undefined) { + setShowAnnouncements(announcementsFromParent.length > 0); + + return; + } fetchAnnouncements(); - }, [fetchAnnouncements]); + }, [announcementsFromParent, fetchAnnouncements]); return (
getFeedCounts(EntityType.PIPELINE, pipelineFQN, handleFeedCount); + const fetchTaskCounts = useCallback(() => { + if (pipelineFQN) { + fetchEntityTaskCountsInto(pipelineFQN, setFeedCount); + } + }, [pipelineFQN]); + + const fetchActivityCount = useCallback(() => { + if (pipelineFQN) { + fetchEntityActivityCountInto( + EntityType.PIPELINE, + pipelineFQN, + setFeedCount + ); + } + }, [pipelineFQN]); + const fetchResourcePermission = useCallback(async () => { try { const entityPermission = await getEntityPermission( @@ -286,8 +306,9 @@ const PipelineDetails = ({ ); useEffect(() => { - getEntityFeedCount(); - }, []); + fetchTaskCounts(); + fetchActivityCount(); + }, [pipelineFQN]); const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineTaskTab/PipelineTaskTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineTaskTab/PipelineTaskTab.tsx index 2a63ad2f5785..eb959e38c8f8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineTaskTab/PipelineTaskTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineTaskTab/PipelineTaskTab.tsx @@ -15,7 +15,14 @@ import { Card, Segmented, Typography } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import { groupBy, isEmpty, isUndefined, uniqBy } from 'lodash'; import { EntityTags, TagFilterOptions } from 'Models'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ReactComponent as ExternalLinkIcon } from '../../../assets/svg/external-links.svg'; @@ -57,7 +64,16 @@ import { ColumnFilter } from '../../Database/ColumnFilter/ColumnFilter.component import TableDescription from '../../Database/TableDescription/TableDescription.component'; import TableTags from '../../Database/TableTags/TableTags.component'; import { ModalWithMarkdownEditor } from '../../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor'; -import TasksDAGView from '../TasksDAGView/TasksDAGView'; + +// TasksDAGView pulls in @xyflow/react via the EntityLineage helpers it shares +// with the Lineage tab. Eagerly importing it leaks ~90 KB brotli of reactflow +// into the entry chunk because PipelineTaskTab is reachable from +// PipelineDetailsUtils + GenericWidgetUtils (both eager). Lazy-loading here +// breaks that chain — Vite drops reactflow + the lineage helpers out of the +// entry preload list; the DAG view chunk loads when the user actually opens +// the Tasks tab. Suspense fallback is `null` because the surrounding Card +// already has its own title/skeleton; a spinner here would flash once. +const TasksDAGView = lazy(() => import('../TasksDAGView/TasksDAGView')); export const PipelineTaskTab = () => { const { @@ -169,10 +185,12 @@ export const PipelineTaskTab = () => { !isEmpty(pipelineDetails.tasks) && !isUndefined(pipelineDetails.tasks) ? (
- + + +
) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.test.tsx index 750b908b7c15..88468112b14f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchedData/SearchedData.test.tsx @@ -11,18 +11,32 @@ * limitations under the License. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getAllByTestId, getByTestId, getByText, render, } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; import { MemoryRouter } from 'react-router'; import { TAG_CONSTANT } from '../../constants/Tag.constants'; import { SearchIndex } from '../../enums/search.enum'; import SearchedData from './SearchedData'; import { SearchedDataProps } from './SearchedData.interface'; +const TestWrapper = ({ children }: PropsWithChildren) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return ( + + {children} + + ); +}; + const mockData: SearchedDataProps['data'] = [ { _index: SearchIndex.TABLE, @@ -122,7 +136,7 @@ const MOCK_PROPS = { describe('Test SearchedData Component', () => { it('Component should render', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const searchedDataContainer = getByTestId(container, 'search-container'); @@ -132,7 +146,7 @@ describe('Test SearchedData Component', () => { it('Should display table card according to data provided in props', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const card1 = getByTestId(container, 'table-data-card_fullyQualifiedName1'); const card2 = getByTestId(container, 'table-data-card_fullyQualifiedName2'); @@ -145,7 +159,7 @@ describe('Test SearchedData Component', () => { it('Should display table card with name and display name highlighted', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const card1 = getByTestId(container, 'table-data-card_fullyQualifiedName1'); @@ -168,7 +182,7 @@ describe('Test SearchedData Component', () => { it('Should display table card with description highlighted', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const card1 = getByTestId(container, 'table-data-card_fullyQualifiedName1'); @@ -193,7 +207,7 @@ describe('Test SearchedData Component', () => {

hello world

, { - wrapper: MemoryRouter, + wrapper: TestWrapper, } ); @@ -204,7 +218,7 @@ describe('Test SearchedData Component', () => { const { container } = render( , { - wrapper: MemoryRouter, + wrapper: TestWrapper, } ); @@ -213,7 +227,7 @@ describe('Test SearchedData Component', () => { it('Component should render highlights', () => { const { container } = render(, { - wrapper: MemoryRouter, + wrapper: TestWrapper, }); const searchedDataContainer = getByTestId(container, 'search-container'); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppSchedule/AppSchedule.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppSchedule/AppSchedule.component.tsx index 210cb5516351..bf5ded8710e9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppSchedule/AppSchedule.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppSchedule/AppSchedule.component.tsx @@ -11,7 +11,6 @@ * limitations under the License. */ import { Button, Col, Modal, Row, Space, Typography } from 'antd'; -import cronstrue from 'cronstrue'; import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -84,16 +83,30 @@ const AppSchedule = ({ } }, [appData]); - const cronString = useMemo(() => { + const [cronString, setCronString] = useState(''); + + useEffect(() => { const cronExpression = (appData.appSchedule as AppScheduleClass) ?.cronExpression; - if (cronExpression) { - return cronstrue.toString(cronExpression, { - throwExceptionOnParseError: false, - }); + if (!cronExpression) { + setCronString(''); + + return; } + let cancelled = false; + import('cronstrue').then((m) => { + if (!cancelled) { + setCronString( + m.default.toString(cronExpression, { + throwExceptionOnParseError: false, + }) + ); + } + }); - return ''; + return () => { + cancelled = true; + }; }, [appData]); const onDialogCancel = () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppSchedule/AppSchedule.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppSchedule/AppSchedule.test.tsx index f0d0ccdbd046..6a24f6b85669 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppSchedule/AppSchedule.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Applications/AppSchedule/AppSchedule.test.tsx @@ -121,12 +121,17 @@ jest.mock('../../../../utils/SchedularUtils', () => ({ })); describe('AppSchedule component', () => { - it('should render necessary elements for mockProps1', () => { + it('should render necessary elements for mockProps1', async () => { render(); expect(screen.getByText('label.schedule-type')).toBeInTheDocument(); - expect(screen.getByText('label.schedule-interval')).toBeInTheDocument(); - expect(screen.getByTestId('cron-string')).toBeInTheDocument(); + // label.schedule-interval is gated on cronString being non-empty, which + // now resolves async after cronstrue lazy-loads (PR-7 of bundle-size + // follow-up). findBy* awaits the async render. + expect( + await screen.findByText('label.schedule-interval') + ).toBeInTheDocument(); + expect(await screen.findByTestId('cron-string')).toBeInTheDocument(); expect(screen.getByText('Modal is close')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'label.edit' })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.tsx index 4bf631dae65e..f711e22413a4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleInterval.tsx @@ -26,9 +26,8 @@ import { Typography, } from 'antd'; import classNames from 'classnames'; -import cronstrue from 'cronstrue/i18n'; import { isEmpty } from 'lodash'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DAY_IN_MONTH_OPTIONS, @@ -104,6 +103,32 @@ const ScheduleInterval = ({ ); const [form] = Form.useForm(); const { cron: cronString, selectedPeriod, dow, dom } = state; + const [cronHumanText, setCronHumanText] = useState(''); + + useEffect(() => { + if (!cronString) { + setCronHumanText(''); + + return; + } + let cancelled = false; + import('cronstrue/i18n').then((m) => { + if (!cancelled) { + setCronHumanText( + m.default.toString(cronString, { + use24HourTimeFormat: false, + verbose: true, + locale: getCurrentLocaleForConstrue(), + throwExceptionOnParseError: false, + }) + ); + } + }); + + return () => { + cancelled = true; + }; + }, [cronString]); const { showMinuteSelect, @@ -369,16 +394,7 @@ const ScheduleInterval = ({ - {cronString && ( - - {cronstrue.toString(cronString, { - use24HourTimeFormat: false, - verbose: true, - locale: getCurrentLocaleForConstrue(), // To get localized string - throwExceptionOnParseError: false, - })} - - )} + {cronString && {cronHumanText}} {isEmpty(cronString) && ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleIntervalV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleIntervalV1.tsx index c9b261496998..9795b4d2eb56 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleIntervalV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/Steps/ScheduleIntervalV1.tsx @@ -13,7 +13,6 @@ import { InfoCircleOutlined } from '@ant-design/icons'; import { Card, Col, Input, Radio, Row, Select, Typography } from 'antd'; -import cronstrue from 'cronstrue/i18n'; import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -178,16 +177,38 @@ const ScheduleIntervalV1: React.FC = ({ })); }, [includePeriodOptions]); - const cronExpressionCard = useMemo(() => { - const cronStringValue = cronString - ? t('label.entity-scheduled-to-run-value', { - entity: entity ?? t('label.ingestion'), - value: cronstrue.toString(cronString, { + const [cronHumanText, setCronHumanText] = useState(''); + + useEffect(() => { + if (!cronString) { + setCronHumanText(''); + + return; + } + let cancelled = false; + import('cronstrue/i18n').then((m) => { + if (!cancelled) { + setCronHumanText( + m.default.toString(cronString, { use24HourTimeFormat: false, verbose: true, locale: getCurrentLocaleForConstrue(), throwExceptionOnParseError: false, - }), + }) + ); + } + }); + + return () => { + cancelled = true; + }; + }, [cronString]); + + const cronExpressionCard = useMemo(() => { + const cronStringValue = cronString + ? t('label.entity-scheduled-to-run-value', { + entity: entity ?? t('label.ingestion'), + value: cronHumanText, }) : t('message.pipeline-will-trigger-manually'); @@ -201,7 +222,7 @@ const ScheduleIntervalV1: React.FC = ({ ); - }, [cronString, entity]); + }, [cronString, cronHumanText, entity, t]); // Update internal state when external value changes useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.test.tsx index 20e864d9ed8a..eb25e9eda14e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.test.tsx @@ -73,43 +73,43 @@ const formData = { }; jest.mock('../../../../utils/DatabaseServiceUtils', () => ({ - getDatabaseConfig: jest.fn().mockReturnValue({ + getDatabaseConfig: jest.fn().mockResolvedValue({ schema: MOCK_ATHENA_SERVICE, }), })); jest.mock('../../../../utils/DashboardServiceUtils', () => ({ - getDashboardConfig: jest.fn().mockReturnValue({ + getDashboardConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); jest.mock('../../../../utils/MessagingServiceUtils', () => ({ - getMessagingConfig: jest.fn().mockReturnValue({ + getMessagingConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); jest.mock('../../../../utils/MetadataServiceUtils', () => ({ - getMetadataConfig: jest.fn().mockReturnValue({ + getMetadataConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); jest.mock('../../../../utils/MlmodelServiceUtils', () => ({ - getMlmodelConfig: jest.fn().mockReturnValue({ + getMlmodelConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); jest.mock('../../../../utils/PipelineServiceUtils', () => ({ - getPipelineConfig: jest.fn().mockReturnValue({ + getPipelineConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); jest.mock('../../../../utils/SearchServiceUtils', () => ({ - getSearchServiceConfig: jest.fn().mockReturnValue({ + getSearchServiceConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); @@ -119,7 +119,7 @@ jest.mock('../../../../utils/JSONSchemaFormUtils', () => ({ })); jest.mock('../../../../utils/ServiceConnectionUtils', () => ({ - getConnectionSchemas: jest.fn().mockReturnValue({ + getConnectionSchemas: jest.fn().mockResolvedValue({ connSch: { schema: { name: 'test', @@ -128,6 +128,7 @@ jest.mock('../../../../utils/ServiceConnectionUtils', () => ({ }, validConfig: {}, }), + EMPTY_CONNECTION_SCHEMA: { schema: {}, uiSchema: {} }, getFilteredSchema: jest.fn().mockReturnValue({}), getUISchemaWithNestedDefaultFilterFieldsHidden: jest.fn().mockReturnValue({}), })); @@ -229,7 +230,9 @@ describe('ServiceConfig', () => { }); it('should not render no config available message if form data has schema', async () => { - render(); + await act(async () => { + render(); + }); expect(screen.queryByTestId('no-config-available')).not.toBeInTheDocument(); }); @@ -247,7 +250,7 @@ describe('ServiceConfig', () => { }); it('should render no config available if form data has no schema', async () => { - (getConnectionSchemas as jest.Mock).mockReturnValueOnce({ + (getConnectionSchemas as jest.Mock).mockResolvedValueOnce({ connSch: { schema: {}, uiSchema: {}, @@ -269,7 +272,9 @@ describe('ServiceConfig', () => { ).mockImplementation(() => ({ ...formData, })); - render(); + await act(async () => { + render(); + }); const submitButton = await screen.findByTestId('submit-button'); fireEvent.click(submitButton); @@ -281,7 +286,7 @@ describe('ServiceConfig', () => { (getPipelineServiceHostIp as jest.Mock).mockRejectedValue(new Error()); render(); await act(async () => { - expect(await screen.queryByTestId('ip-address')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ip-address')).not.toBeInTheDocument(); }); }); @@ -294,12 +299,11 @@ describe('ServiceConfig', () => { })); render(); await act(async () => { - expect(await screen.queryByTestId('ip-address')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ip-address')).not.toBeInTheDocument(); }); }); it('should render with correct brandName (OpenMetadata or Collate)', async () => { - // Mock Transi18next to actually render interpolated values const mockTransi18next = jest.fn(({ values }) => (
{values?.hostIp && `Host IP: ${values.hostIp}`} @@ -317,11 +321,9 @@ describe('ServiceConfig', () => { expect(ipAddress).toBeInTheDocument(); - // Verify actual brand name is rendered expect(ipAddress.textContent).toMatch(/OpenMetadata|Collate/); expect(ipAddress.textContent).not.toContain('{{brandName}}'); - // Verify Transi18next was called with brandName parameter expect(mockTransi18next).toHaveBeenCalledWith( expect.objectContaining({ i18nKey: 'message.airflow-host-ip-address', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.tsx index f2ec061f0df1..e4e1b72a364d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/ConnectionConfigForm.tsx @@ -33,6 +33,8 @@ import brandClassBase from '../../../../utils/BrandData/BrandClassBase'; import i18n, { Transi18next } from '../../../../utils/i18next/LocalUtil'; import { formatFormDataForSubmit } from '../../../../utils/JSONSchemaFormUtils'; import { + ConnectionSchemaResult, + EMPTY_CONNECTION_SCHEMA, getConnectionSchemas, getFilteredSchema, getUISchemaWithNestedDefaultFilterFieldsHidden, @@ -65,6 +67,11 @@ const ConnectionConfigForm = ({ const { isAirflowAvailable, platform } = useAirflowStatus(); const [hostIp, setHostIp] = useState(); + const [connectionSchemaResult, setConnectionSchemaResult] = + useState({ + connSch: EMPTY_CONNECTION_SCHEMA, + validConfig: {} as ConfigData, + }); const fetchHostIp = async () => { try { @@ -85,6 +92,28 @@ const ConnectionConfigForm = ({ } }, [isAirflowAvailable]); + useEffect(() => { + let cancelled = false; + getConnectionSchemas({ data, serviceCategory, serviceType }) + .then((result) => { + if (!cancelled) { + setConnectionSchemaResult(result); + } + }) + .catch(() => { + if (!cancelled) { + setConnectionSchemaResult({ + connSch: EMPTY_CONNECTION_SCHEMA, + validConfig: {} as ConfigData, + }); + } + }); + + return () => { + cancelled = true; + }; + }, [data, serviceCategory, serviceType]); + const handleRequiredFieldsValidation = () => { return Boolean(formRef.current?.validateForm()); }; @@ -100,15 +129,7 @@ const ConnectionConfigForm = ({ ArrayField: WorkflowArrayFieldTemplate, }; - const { connSch, validConfig } = useMemo( - () => - getConnectionSchemas({ - data, - serviceCategory, - serviceType, - }), - [data, serviceCategory, serviceType] - ); + const { connSch, validConfig } = connectionSchemaResult; const connectionSchema = connSch.schema as RJSFSchema; const shouldShowIPAlert = useMemo(() => { @@ -122,8 +143,6 @@ const ConnectionConfigForm = ({ ); }, [connSch.schema, isAirflowAvailable, hostIp, platform, ingestionRunner]); - // Remove the filters property from the schema - // Since it'll have a separate form in the next step const propertiesWithoutDefaultFilterPatternFields = useMemo( () => getFilteredSchema( @@ -141,8 +160,6 @@ const ConnectionConfigForm = ({ [connectionSchema, propertiesWithoutDefaultFilterPatternFields] ); - // UI Schema to hide the nested default filter pattern fields - // Since some connections have reference to the other connections const uiSchema = useMemo(() => { return getUISchemaWithNestedDefaultFilterFieldsHidden(connSch.uiSchema); }, [connSch.uiSchema]); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/EmbeddedConnectionConfigForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/EmbeddedConnectionConfigForm.test.tsx index 6b458427e146..d53c5d914aa5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/EmbeddedConnectionConfigForm.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/EmbeddedConnectionConfigForm.test.tsx @@ -16,7 +16,6 @@ import { act, forwardRef } from 'react'; import { LOADING_STATE } from '../../../../enums/common.enum'; import { ServiceCategory } from '../../../../enums/service.enum'; import { MOCK_ATHENA_SERVICE } from '../../../../mocks/Service.mock'; -import { getPipelineServiceHostIp } from '../../../../rest/ingestionPipelineAPI'; import * as LocalUtils from '../../../../utils/i18next/LocalUtil'; import { formatFormDataForSubmit } from '../../../../utils/JSONSchemaFormUtils'; import { getConnectionSchemas } from '../../../../utils/ServiceConnectionUtils'; @@ -31,31 +30,33 @@ const formData = { }; jest.mock('../../../../utils/DatabaseServiceUtils', () => ({ - getDatabaseConfig: jest.fn().mockReturnValue({ schema: MOCK_ATHENA_SERVICE }), + getDatabaseConfig: jest + .fn() + .mockResolvedValue({ schema: MOCK_ATHENA_SERVICE }), })); jest.mock('../../../../utils/DashboardServiceUtils', () => ({ - getDashboardConfig: jest.fn().mockReturnValue({ schema: {} }), + getDashboardConfig: jest.fn().mockResolvedValue({ schema: {} }), })); jest.mock('../../../../utils/MessagingServiceUtils', () => ({ - getMessagingConfig: jest.fn().mockReturnValue({ schema: {} }), + getMessagingConfig: jest.fn().mockResolvedValue({ schema: {} }), })); jest.mock('../../../../utils/MetadataServiceUtils', () => ({ - getMetadataConfig: jest.fn().mockReturnValue({ schema: {} }), + getMetadataConfig: jest.fn().mockResolvedValue({ schema: {} }), })); jest.mock('../../../../utils/MlmodelServiceUtils', () => ({ - getMlmodelConfig: jest.fn().mockReturnValue({ schema: {} }), + getMlmodelConfig: jest.fn().mockResolvedValue({ schema: {} }), })); jest.mock('../../../../utils/PipelineServiceUtils', () => ({ - getPipelineConfig: jest.fn().mockReturnValue({ schema: {} }), + getPipelineConfig: jest.fn().mockResolvedValue({ schema: {} }), })); jest.mock('../../../../utils/SearchServiceUtils', () => ({ - getSearchServiceConfig: jest.fn().mockReturnValue({ schema: {} }), + getSearchServiceConfig: jest.fn().mockResolvedValue({ schema: {} }), })); jest.mock('../../../../utils/JSONSchemaFormUtils', () => ({ @@ -63,10 +64,11 @@ jest.mock('../../../../utils/JSONSchemaFormUtils', () => ({ })); jest.mock('../../../../utils/ServiceConnectionUtils', () => ({ - getConnectionSchemas: jest.fn().mockReturnValue({ + getConnectionSchemas: jest.fn().mockResolvedValue({ connSch: { schema: { name: 'test' }, uiSchema: {} }, validConfig: {}, }), + EMPTY_CONNECTION_SCHEMA: { schema: {}, uiSchema: {} }, getFilteredSchema: jest.fn().mockReturnValue({}), getUISchemaWithNestedDefaultFilterFieldsHidden: jest.fn().mockReturnValue({}), })); @@ -165,13 +167,15 @@ describe('EmbeddedConnectionConfigForm', () => { }); it('does not show no-config message when schema has content', async () => { - render(); + await act(async () => { + render(); + }); expect(screen.queryByTestId('no-config-available')).not.toBeInTheDocument(); }); it('shows no-config message when schema is empty', async () => { - (getConnectionSchemas as jest.Mock).mockReturnValueOnce({ + (getConnectionSchemas as jest.Mock).mockResolvedValueOnce({ connSch: { schema: {}, uiSchema: {} }, validConfig: {}, }); @@ -197,21 +201,16 @@ describe('EmbeddedConnectionConfigForm', () => { const mockFormatted = { ...formData }; (formatFormDataForSubmit as jest.Mock).mockReturnValue(mockFormatted); - render(); + await act(async () => { + render(); + }); + const submitButton = await screen.findByTestId('submit-button'); - fireEvent.click(await screen.findByTestId('submit-button')); + fireEvent.click(submitButton); expect(formatFormDataForSubmit).toHaveBeenCalledWith(formData); - expect(mockOnSave).toHaveBeenCalled(); - }); - - it('does not show IP alert when host IP fetch fails', async () => { - (getPipelineServiceHostIp as jest.Mock).mockRejectedValue(new Error()); - - render(); - - await act(async () => { - expect(screen.queryByTestId('ip-address')).not.toBeInTheDocument(); + expect(mockOnSave).toHaveBeenCalledWith({ + formData: mockFormatted, }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/EmbeddedConnectionConfigForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/EmbeddedConnectionConfigForm.tsx index 6ce42c13795e..1aae1e7ac3ca 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/EmbeddedConnectionConfigForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/EmbeddedConnectionConfigForm.tsx @@ -31,6 +31,8 @@ import brandClassBase from '../../../../utils/BrandData/BrandClassBase'; import i18n, { Transi18next } from '../../../../utils/i18next/LocalUtil'; import { formatFormDataForSubmit } from '../../../../utils/JSONSchemaFormUtils'; import { + ConnectionSchemaResult, + EMPTY_CONNECTION_SCHEMA, getConnectionSchemas, getFilteredSchema, getUISchemaWithNestedDefaultFilterFieldsHidden, @@ -61,6 +63,11 @@ const EmbeddedConnectionConfigForm = ({ const { isAirflowAvailable, platform } = useAirflowStatus(); const [hostIp, setHostIp] = useState(); + const [connectionSchemaResult, setConnectionSchemaResult] = + useState({ + connSch: EMPTY_CONNECTION_SCHEMA, + validConfig: {} as ConfigData, + }); const fetchHostIp = async () => { try { @@ -81,6 +88,28 @@ const EmbeddedConnectionConfigForm = ({ } }, [isAirflowAvailable]); + useEffect(() => { + let cancelled = false; + getConnectionSchemas({ data, serviceCategory, serviceType }) + .then((result) => { + if (!cancelled) { + setConnectionSchemaResult(result); + } + }) + .catch(() => { + if (!cancelled) { + setConnectionSchemaResult({ + connSch: EMPTY_CONNECTION_SCHEMA, + validConfig: {} as ConfigData, + }); + } + }); + + return () => { + cancelled = true; + }; + }, [data, serviceCategory, serviceType]); + const handleRequiredFieldsValidation = () => { return Boolean(formRef.current?.validateForm()); }; @@ -91,15 +120,7 @@ const EmbeddedConnectionConfigForm = ({ await onSave({ ...data, formData: updatedFormData }); }; - const { connSch, validConfig } = useMemo( - () => - getConnectionSchemas({ - data, - serviceCategory, - serviceType, - }), - [data, serviceCategory, serviceType] - ); + const { connSch, validConfig } = connectionSchemaResult; const connectionSchema = connSch.schema as RJSFSchema; const shouldShowIPAlert = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/FiltersConfigForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/FiltersConfigForm.test.tsx index fa18d40f0b3c..3aa87c1249ff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/FiltersConfigForm.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/FiltersConfigForm.test.tsx @@ -12,7 +12,13 @@ */ import { IChangeEvent } from '@rjsf/core'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { LoadingState } from 'Models'; import React from 'react'; import { ServiceCategory } from '../../../../enums/service.enum'; @@ -32,7 +38,7 @@ jest.mock('../../../../utils/JSONSchemaFormUtils', () => ({ })); jest.mock('../../../../utils/ServiceConnectionUtils', () => ({ - getConnectionSchemas: jest.fn().mockReturnValue({ + getConnectionSchemas: jest.fn().mockResolvedValue({ connSch: { schema: { type: 'object', @@ -46,15 +52,18 @@ jest.mock('../../../../utils/ServiceConnectionUtils', () => ({ }, validConfig: {}, }), - getFilteredSchema: jest.fn((properties) => { - const { - filter1: _filter1, - filter2: _filter2, - ...rest - } = properties as Record; - - return rest; - }), + EMPTY_CONNECTION_SCHEMA: { schema: {}, uiSchema: {} }, + getFilteredSchema: jest.fn( + (properties: Record | undefined) => { + const { + filter1: _filter1, + filter2: _filter2, + ...rest + } = (properties || {}) as Record; + + return rest; + } + ), })); const MockFormBuilder = React.forwardRef< @@ -110,6 +119,15 @@ const mockUseApplicationStore = jest.requireMock( '../../../../hooks/useApplicationStore' ).useApplicationStore; +const renderForm = async (props: FiltersConfigFormProps) => { + let utils: ReturnType | undefined; + await act(async () => { + utils = render(); + }); + + return utils!; +}; + describe('FiltersConfigForm', () => { const mockOnSave = jest.fn(); const mockOnCancel = jest.fn(); @@ -127,49 +145,82 @@ describe('FiltersConfigForm', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetConnectionSchemas.mockResolvedValue({ + connSch: { + schema: { + type: 'object', + properties: { + filter1: { type: 'string' }, + filter2: { type: 'string' }, + someOtherProperty: { type: 'string' }, + }, + additionalProperties: true, + }, + }, + validConfig: {}, + }); + mockGetFilteredSchema.mockImplementation( + (properties: Record | undefined) => { + const { + filter1: _filter1, + filter2: _filter2, + ...rest + } = (properties || {}) as Record; + + return rest; + } + ); }); describe('Schema Filtering', () => { - it('should remove filter properties from the schema', () => { - render(); + it('should remove filter properties from the schema', async () => { + await renderForm(defaultProps); - expect(mockGetFilteredSchema).toHaveBeenCalledWith( - { - filter1: { type: 'string' }, - filter2: { type: 'string' }, - someOtherProperty: { type: 'string' }, - }, - false - ); + await waitFor(() => { + expect(mockGetFilteredSchema).toHaveBeenCalledWith( + { + filter1: { type: 'string' }, + filter2: { type: 'string' }, + someOtherProperty: { type: 'string' }, + }, + false + ); + }); }); - it('should set additionalProperties to false in the filtered schema', () => { + it('should set additionalProperties to false in the filtered schema', async () => { const mockFormBuilder = jest.requireMock( '../../../common/FormBuilder/FormBuilder' ); - render(); + await renderForm(defaultProps); - const formBuilderCall = mockFormBuilder.mock.calls[0][0]; + await waitFor(() => { + const lastCall = + mockFormBuilder.mock.calls[mockFormBuilder.mock.calls.length - 1][0]; - expect(formBuilderCall.schema.additionalProperties).toBe(false); + expect(lastCall.schema.additionalProperties).toBe(false); + }); }); - it('should pass the filtered schema to FormBuilder', () => { + it('should pass the filtered schema to FormBuilder', async () => { const mockFormBuilder = jest.requireMock( '../../../common/FormBuilder/FormBuilder' ); - render(); + await renderForm(defaultProps); - const formBuilderCall = mockFormBuilder.mock.calls[0][0]; + await waitFor(() => { + const lastCall = + mockFormBuilder.mock.calls[mockFormBuilder.mock.calls.length - 1][0]; - expect(formBuilderCall.schema).toEqual({ - type: 'object', - properties: { - someOtherProperty: { type: 'string' }, - }, - additionalProperties: false, + expect(lastCall.schema).toEqual({ + type: 'object', + properties: { + someOtherProperty: { type: 'string' }, + }, + additionalProperties: false, + }); }); }); }); @@ -178,7 +229,7 @@ describe('FiltersConfigForm', () => { it('should format and save form data on submit', async () => { mockFormatFormDataForSubmit.mockReturnValue({ formatted: 'data' }); - render(); + await renderForm(defaultProps); const submitButton = screen.getByTestId('submit-button'); fireEvent.click(submitButton); @@ -191,8 +242,8 @@ describe('FiltersConfigForm', () => { }); }); - it('should call onCancel when cancel button is clicked', () => { - render(); + it('should call onCancel when cancel button is clicked', async () => { + await renderForm(defaultProps); const cancelButton = screen.getByTestId('cancel-button'); fireEvent.click(cancelButton); @@ -202,21 +253,20 @@ describe('FiltersConfigForm', () => { }); describe('Empty Schema Handling', () => { - it('should not show no config message with default mock (schema has properties)', () => { - // Default mock has properties, so no-config message shouldn't show - render(); + it('should not show no config message with default mock (schema has properties)', async () => { + await renderForm(defaultProps); expect( screen.queryByTestId('no-config-available') ).not.toBeInTheDocument(); }); - it('should not show no config message when schema has properties', () => { + it('should not show no config message when schema has properties', async () => { mockGetFilteredSchema.mockReturnValue({ someProperty: { type: 'string' }, }); - render(); + await renderForm(defaultProps); expect( screen.queryByTestId('no-config-available') @@ -225,7 +275,7 @@ describe('FiltersConfigForm', () => { }); describe('Inline Alert', () => { - it('should render inline alert when inlineAlertDetails is present', () => { + it('should render inline alert when inlineAlertDetails is present', async () => { mockUseApplicationStore.mockReturnValue({ inlineAlertDetails: { type: 'error', @@ -233,76 +283,75 @@ describe('FiltersConfigForm', () => { }, }); - render(); + await renderForm(defaultProps); expect(screen.getByTestId('inline-alert')).toBeInTheDocument(); }); - it('should not render inline alert when inlineAlertDetails is undefined', () => { + it('should not render inline alert when inlineAlertDetails is undefined', async () => { mockUseApplicationStore.mockReturnValue({ inlineAlertDetails: undefined, }); - render(); + await renderForm(defaultProps); expect(screen.queryByTestId('inline-alert')).not.toBeInTheDocument(); }); }); describe('Props Handling', () => { - it('should use custom okText and cancelText when provided', () => { + it('should use custom okText and cancelText when provided', async () => { const mockFormBuilder = jest.requireMock( '../../../common/FormBuilder/FormBuilder' ); - render( - - ); + await renderForm({ + ...defaultProps, + cancelText: 'Custom Cancel', + okText: 'Custom Save', + }); - const formBuilderCall = mockFormBuilder.mock.calls[0][0]; + const lastCall = + mockFormBuilder.mock.calls[mockFormBuilder.mock.calls.length - 1][0]; - expect(formBuilderCall.okText).toBe('Custom Save'); - expect(formBuilderCall.cancelText).toBe('Custom Cancel'); + expect(lastCall.okText).toBe('Custom Save'); + expect(lastCall.cancelText).toBe('Custom Cancel'); }); - it('should use default okText and cancelText when not provided', () => { + it('should use default okText and cancelText when not provided', async () => { const mockFormBuilder = jest.requireMock( '../../../common/FormBuilder/FormBuilder' ); - render(); + await renderForm(defaultProps); - const formBuilderCall = mockFormBuilder.mock.calls[0][0]; + const lastCall = + mockFormBuilder.mock.calls[mockFormBuilder.mock.calls.length - 1][0]; - expect(formBuilderCall.okText).toBe('Save'); - expect(formBuilderCall.cancelText).toBe('Cancel'); + expect(lastCall.okText).toBe('Save'); + expect(lastCall.cancelText).toBe('Cancel'); }); - it('should pass all required props to FormBuilder', () => { + it('should pass all required props to FormBuilder', async () => { const mockFormBuilder = jest.requireMock( '../../../common/FormBuilder/FormBuilder' ); - render(); + await renderForm(defaultProps); - const formBuilderCall = mockFormBuilder.mock.calls[0][0]; + const lastCall = + mockFormBuilder.mock.calls[mockFormBuilder.mock.calls.length - 1][0]; - expect(formBuilderCall.serviceCategory).toBe( - ServiceCategory.DATABASE_SERVICES - ); - expect(formBuilderCall.status).toBe('initial'); - expect(formBuilderCall.onFocus).toBe(mockOnFocus); - expect(formBuilderCall.showFormHeader).toBe(true); + expect(lastCall.serviceCategory).toBe(ServiceCategory.DATABASE_SERVICES); + expect(lastCall.status).toBe('initial'); + expect(lastCall.onFocus).toBe(mockOnFocus); + expect(lastCall.showFormHeader).toBe(true); }); }); describe('Connection Schema Integration', () => { - it('should call getConnectionSchemas with correct parameters', () => { - render(); + it('should call getConnectionSchemas with correct parameters', async () => { + await renderForm(defaultProps); expect(mockGetConnectionSchemas).toHaveBeenCalledWith({ data: undefined, @@ -311,8 +360,8 @@ describe('FiltersConfigForm', () => { }); }); - it('should use validConfig from getConnectionSchemas', () => { - mockGetConnectionSchemas.mockReturnValue({ + it('should use validConfig from getConnectionSchemas', async () => { + mockGetConnectionSchemas.mockResolvedValue({ connSch: { schema: { type: 'object', @@ -326,11 +375,14 @@ describe('FiltersConfigForm', () => { '../../../common/FormBuilder/FormBuilder' ); - render(); + await renderForm(defaultProps); - const formBuilderCall = mockFormBuilder.mock.calls[0][0]; + await waitFor(() => { + const lastCall = + mockFormBuilder.mock.calls[mockFormBuilder.mock.calls.length - 1][0]; - expect(formBuilderCall.formData).toEqual({ customConfig: 'value' }); + expect(lastCall.formData).toEqual({ customConfig: 'value' }); + }); }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/FiltersConfigForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/FiltersConfigForm.tsx index 4947717bc34f..c2ecd29387f0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/FiltersConfigForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConfig/FiltersConfigForm.tsx @@ -11,16 +11,18 @@ * limitations under the License. */ import Form, { IChangeEvent } from '@rjsf/core'; -import { RegistryFieldsType } from '@rjsf/utils'; +import { RegistryFieldsType, RJSFSchema } from '@rjsf/utils'; import validator from '@rjsf/validator-ajv8'; import { isEmpty, isUndefined } from 'lodash'; -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SERVICE_CONNECTION_UI_SCHEMA } from '../../../../constants/ServiceConnection.constants'; import { useApplicationStore } from '../../../../hooks/useApplicationStore'; import { ConfigData } from '../../../../interface/service.interface'; import { formatFormDataForSubmit } from '../../../../utils/JSONSchemaFormUtils'; import { + ConnectionSchemaResult, + EMPTY_CONNECTION_SCHEMA, getConnectionSchemas, getFilteredSchema, } from '../../../../utils/ServiceConnectionUtils'; @@ -46,11 +48,35 @@ function FiltersConfigForm({ const customFields: RegistryFieldsType = { ArrayField: WorkflowArrayFieldTemplate, }; - const { connSch, validConfig } = getConnectionSchemas({ - data, - serviceCategory, - serviceType, - }); + const [connectionSchemaResult, setConnectionSchemaResult] = + useState({ + connSch: EMPTY_CONNECTION_SCHEMA, + validConfig: {} as ConfigData, + }); + + useEffect(() => { + let cancelled = false; + getConnectionSchemas({ data, serviceCategory, serviceType }) + .then((result) => { + if (!cancelled) { + setConnectionSchemaResult(result); + } + }) + .catch(() => { + if (!cancelled) { + setConnectionSchemaResult({ + connSch: EMPTY_CONNECTION_SCHEMA, + validConfig: {} as ConfigData, + }); + } + }); + + return () => { + cancelled = true; + }; + }, [data, serviceCategory, serviceType]); + + const { connSch, validConfig } = connectionSchemaResult; const handleSave = async (data: IChangeEvent) => { const updatedFormData = formatFormDataForSubmit(data.formData); @@ -58,20 +84,18 @@ function FiltersConfigForm({ await onSave({ ...data, formData: updatedFormData }); }; - // Remove the filters property from the schema - // Since it'll have a separate form in the next step - const filteredSchema = useMemo(() => { + const filteredSchema = useMemo(() => { const propertiesWithoutFilters = getFilteredSchema( - connSch.schema.properties, + connSch.schema.properties as Record | undefined, false ); return { - ...connSch.schema, - properties: propertiesWithoutFilters, - additionalProperties: false, // Disable additional properties for default filters form - }; - }, [connSch.schema.properties]); + ...(connSch.schema as Record), + properties: propertiesWithoutFilters as RJSFSchema['properties'], + additionalProperties: false, + } as RJSFSchema; + }, [connSch.schema]); return ( }> => { + switch (serviceCategory.slice(0, -1)) { + case EntityType.DATABASE_SERVICE: + return serviceUtilClassBase.getDatabaseServiceConfig( + serviceFQN as DatabaseServiceType + ); + case EntityType.DASHBOARD_SERVICE: + return serviceUtilClassBase.getDashboardServiceConfig( + serviceFQN as DashboardServiceType + ); + case EntityType.MESSAGING_SERVICE: + return serviceUtilClassBase.getMessagingServiceConfig( + serviceFQN as MessagingServiceType + ); + case EntityType.PIPELINE_SERVICE: + return serviceUtilClassBase.getPipelineServiceConfig( + serviceFQN as PipelineServiceType + ); + case EntityType.MLMODEL_SERVICE: + return serviceUtilClassBase.getMlModelServiceConfig( + serviceFQN as MlModelServiceType + ); + case EntityType.METADATA_SERVICE: + return serviceUtilClassBase.getMetadataServiceConfig( + serviceFQN as MetadataServiceType + ); + case EntityType.STORAGE_SERVICE: + return serviceUtilClassBase.getStorageServiceConfig( + serviceFQN as StorageServiceType + ); + case EntityType.SEARCH_SERVICE: + return serviceUtilClassBase.getSearchServiceConfig( + serviceFQN as SearchServiceType + ); + case EntityType.API_SERVICE: + return serviceUtilClassBase.getAPIServiceConfig( + serviceFQN as APIServiceType + ); + case EntityType.SECURITY_SERVICE: + return serviceUtilClassBase.getSecurityServiceConfig( + serviceFQN as SecurityServiceType + ); + case EntityType.DRIVE_SERVICE: + return serviceUtilClassBase.getDriveServiceConfig( + serviceFQN as DriveServiceType + ); + default: + return Promise.resolve({ schema: {} }); + } +}; + const ServiceConnectionDetails = ({ connectionDetails, serviceCategory, @@ -52,103 +106,29 @@ const ServiceConnectionDetails = ({ const [data, setData] = useState(); useEffect(() => { - switch (serviceCategory.slice(0, -1)) { - case EntityType.DATABASE_SERVICE: - setSchema( - serviceUtilClassBase.getDatabaseServiceConfig( - serviceFQN as DatabaseServiceType - ).schema - ); - - break; - case EntityType.DASHBOARD_SERVICE: - setSchema( - serviceUtilClassBase.getDashboardServiceConfig( - serviceFQN as DashboardServiceType - ).schema - ); - - break; - case EntityType.MESSAGING_SERVICE: - setSchema( - serviceUtilClassBase.getMessagingServiceConfig( - serviceFQN as MessagingServiceType - ).schema - ); - - break; - case EntityType.PIPELINE_SERVICE: - setSchema( - serviceUtilClassBase.getPipelineServiceConfig( - serviceFQN as PipelineServiceType - ).schema - ); - - break; - case EntityType.MLMODEL_SERVICE: - setSchema( - serviceUtilClassBase.getMlModelServiceConfig( - serviceFQN as MlModelServiceType - ).schema - ); - - break; - case EntityType.METADATA_SERVICE: - setSchema( - serviceUtilClassBase.getMetadataServiceConfig( - serviceFQN as MetadataServiceType - ).schema - ); - - break; - case EntityType.STORAGE_SERVICE: - setSchema( - serviceUtilClassBase.getStorageServiceConfig( - serviceFQN as StorageServiceType - ).schema - ); - - break; - case EntityType.SEARCH_SERVICE: - setSchema( - serviceUtilClassBase.getSearchServiceConfig( - serviceFQN as SearchServiceType - ).schema - ); - - break; - - case EntityType.API_SERVICE: - setSchema( - serviceUtilClassBase.getAPIServiceConfig(serviceFQN as APIServiceType) - .schema - ); - - break; - case EntityType.SECURITY_SERVICE: - setSchema( - serviceUtilClassBase.getSecurityServiceConfig( - serviceFQN as SecurityServiceType - ).schema - ); - - break; - case EntityType.DRIVE_SERVICE: - setSchema( - serviceUtilClassBase.getDriveServiceConfig( - serviceFQN as DriveServiceType - ).schema - ); - - break; - } + let cancelled = false; + loadSchemaForServiceCategory(serviceCategory, serviceFQN) + .then((result) => { + if (!cancelled) { + setSchema(result.schema); + } + }) + .catch(() => { + if (!cancelled) { + setSchema({}); + } + }); + + return () => { + cancelled = true; + }; }, [serviceCategory, serviceFQN]); useEffect(() => { if (!isEmpty(schema)) { setData( getKeyValues({ - obj: connectionDetails, + obj: connectionDetails as unknown as Record, schemaPropertyObject: schema.properties, schema, serviceCategory, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConnectionDetails/ServiceConnectionDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConnectionDetails/ServiceConnectionDetails.test.tsx index 91bcb738b142..0addb3a78638 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConnectionDetails/ServiceConnectionDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/ServiceConnectionDetails/ServiceConnectionDetails.test.tsx @@ -29,43 +29,43 @@ import { getSearchServiceConfig } from '../../../../utils/SearchServiceUtils'; import ServiceConnectionDetails from './ServiceConnectionDetails.component'; jest.mock('../../../../utils/DatabaseServiceUtils', () => ({ - getDatabaseConfig: jest.fn().mockReturnValue({ + getDatabaseConfig: jest.fn().mockResolvedValue({ schema: MOCK_ATHENA_SERVICE, }), })); jest.mock('../../../../utils/DashboardServiceUtils', () => ({ - getDashboardConfig: jest.fn().mockReturnValue({ + getDashboardConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); jest.mock('../../../../utils/MessagingServiceUtils', () => ({ - getMessagingConfig: jest.fn().mockReturnValue({ + getMessagingConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); jest.mock('../../../../utils/MetadataServiceUtils', () => ({ - getMetadataConfig: jest.fn().mockReturnValue({ + getMetadataConfig: jest.fn().mockResolvedValue({ schema: ATLAS_CONNECTION, }), })); jest.mock('../../../../utils/MlmodelServiceUtils', () => ({ - getMlmodelConfig: jest.fn().mockReturnValue({ + getMlmodelConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); jest.mock('../../../../utils/PipelineServiceUtils', () => ({ - getPipelineConfig: jest.fn().mockReturnValue({ + getPipelineConfig: jest.fn().mockResolvedValue({ schema: AIR_BYTE_CONNECTION, }), })); jest.mock('../../../../utils/SearchServiceUtils', () => ({ - getSearchServiceConfig: jest.fn().mockReturnValue({ + getSearchServiceConfig: jest.fn().mockResolvedValue({ schema: {}, }), })); @@ -131,13 +131,15 @@ const services = [ describe('ServiceConnectionDetails', () => { it('renders Service Connection Details', async () => { - render( - - ); + await act(async () => { + render( + + ); + }); expect( await screen.findByTestId('service-connection-details') @@ -190,16 +192,17 @@ describe('ServiceConnectionDetails', () => { services.map((service) => { it(`should render ${service.name} service`, async () => { - render( - - ); await act(async () => { - expect(service.configVal).toHaveBeenCalled(); + render( + + ); }); + + expect(service.configVal).toHaveBeenCalled(); }); }); @@ -234,13 +237,15 @@ describe('ServiceConnectionDetails', () => { }); it('should render metadata service', async () => { - render( - - ); + await act(async () => { + render( + + ); + }); expect(await screen.findByText('username:')).toBeInTheDocument(); expect(await screen.queryAllByTestId('input-field')[0]).toHaveValue( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.test.tsx index 884f4830885e..2c78c213e077 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.test.tsx @@ -93,6 +93,8 @@ jest.mock('../../../utils/useRequiredParams', () => ({ })); jest.mock('../../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx index 955fbce68fe0..834411e12663 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx @@ -32,7 +32,11 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { restoreTopic } from '../../../rest/topicsAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -250,6 +254,22 @@ const TopicDetails: React.FC = ({ const getEntityFeedCount = () => getFeedCounts(EntityType.TOPIC, decodedTopicFQN, handleFeedCount); + const fetchTaskCounts = useCallback(() => { + if (decodedTopicFQN) { + fetchEntityTaskCountsInto(decodedTopicFQN, setFeedCount); + } + }, [decodedTopicFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedTopicFQN) { + fetchEntityActivityCountInto( + EntityType.TOPIC, + decodedTopicFQN, + setFeedCount + ); + } + }, [decodedTopicFQN]); + const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] @@ -303,7 +323,8 @@ const TopicDetails: React.FC = ({ ); useEffect(() => { - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); }, [topicPermissions, decodedTopicFQN]); const tabs = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/CustomBarChart.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/CustomBarChart.tsx index 71523e8e2637..9e4f10c6b224 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/CustomBarChart.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/CustomBarChart.tsx @@ -34,7 +34,7 @@ import { tooltipFormatter, updateActiveChartFilter, } from '../../../utils/ChartUtils'; -import { CustomDQTooltip } from '../../../utils/DataQuality/DataQualityUtils'; +import { CustomDQTooltip } from '../../../utils/DataQuality/CustomDQTooltip.component'; import { formatDateTimeLong } from '../../../utils/date-time/DateTimeUtils'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { CustomBarChartProps } from './Chart.interface'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/DataDistributionHistogram.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/DataDistributionHistogram.component.tsx index b7882de89083..dce524f66a4a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/DataDistributionHistogram.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/DataDistributionHistogram.component.tsx @@ -39,7 +39,7 @@ import { createHorizontalGridLineRenderer, tooltipFormatter, } from '../../../utils/ChartUtils'; -import { CustomDQTooltip } from '../../../utils/DataQuality/DataQualityUtils'; +import { CustomDQTooltip } from '../../../utils/DataQuality/CustomDQTooltip.component'; import { customFormatDateTime } from '../../../utils/date-time/DateTimeUtils'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { DataDistributionHistogramProps } from './Chart.interface'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/OperationDateBarChart.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/OperationDateBarChart.test.tsx index 28456bbeca1e..82061f487b1e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/OperationDateBarChart.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/OperationDateBarChart.test.tsx @@ -35,7 +35,7 @@ const mockCustomBarChartProp: CustomBarChartProps = { }, name: 'testChart', }; -jest.mock('../../../utils/DataInsightUtils', () => { +jest.mock('../../../utils/DataInsightChartUtils', () => { return jest.fn().mockImplementation(() => { return
CustomTooltip
; }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/OperationDateBarChart.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/OperationDateBarChart.tsx index f7b534132809..acaf8a8fb74a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/OperationDateBarChart.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Visualisations/Chart/OperationDateBarChart.tsx @@ -32,7 +32,7 @@ import { tooltipFormatter, updateActiveChartFilter, } from '../../../utils/ChartUtils'; -import { CustomTooltip } from '../../../utils/DataInsightUtils'; +import { CustomTooltip } from '../../../utils/DataInsightChartUtils'; import { formatDateTimeLong } from '../../../utils/date-time/DateTimeUtils'; import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { CustomBarChartProps } from './Chart.interface'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/EditTableTypePropertyModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/EditTableTypePropertyModal.tsx index 43f825a934f2..eba5563f97b5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/EditTableTypePropertyModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/EditTableTypePropertyModal.tsx @@ -13,10 +13,11 @@ import { Button, Modal, Typography } from 'antd'; import { isEmpty, omit } from 'lodash'; import { FC, useCallback, useMemo, useState } from 'react'; -import { Column, textEditor } from 'react-data-grid'; +import type { Column } from 'react-data-grid'; import { useTranslation } from 'react-i18next'; import { useGridEditController } from '../../../../hooks/useGridEditController'; import { getEntityName } from '../../../../utils/EntityUtils'; +import { lazyTextEditor } from '../../DataGrid/LazyDataGrid'; import { KeyDownStopPropagationWrapper } from '../../KeyDownStopPropagationWrapper/KeyDownStopPropagationWrapper'; import { TableTypePropertyValueType } from '../CustomPropertyTable.interface'; import './edit-table-type-property.less'; @@ -32,7 +33,7 @@ export const getGridColumns = (columns: string[]) => { resizable: true, cellClass: () => `rdg-cell-${column.replace(/[^a-zA-Z0-9-_]/g, '')}`, editable: true, - renderEditCell: textEditor, + renderEditCell: lazyTextEditor, minWidth: 180, })) as Column[]>[]; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.interface.ts index 1ee351c6f242..4c2424eb4ca4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.interface.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Column, CopyEvent, PasteEvent } from 'react-data-grid'; +import type { Column, CopyEvent, PasteEvent } from 'react-data-grid'; export interface TableTypePropertyEditTableProps { columns: Column[]>[]; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.tsx index b9aeb6b647bd..161beb785d17 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/TableTypeProperty/TableTypePropertyEditTable.tsx @@ -11,8 +11,8 @@ * limitations under the License. */ import { useMemo } from 'react'; -import DataGrid, { ColumnOrColumnGroup } from 'react-data-grid'; -import 'react-data-grid/lib/styles.css'; +import type { ColumnOrColumnGroup } from 'react-data-grid'; +import { LazyDataGrid } from '../../DataGrid/LazyDataGrid'; import { TableTypePropertyEditTableProps } from './TableTypePropertyEditTable.interface'; const TableTypePropertyEditTable = ({ @@ -26,7 +26,7 @@ const TableTypePropertyEditTable = ({ return useMemo(() => { return (
- { + const [module] = await Promise.all([ + import('react-data-grid'), + import('react-data-grid/lib/styles.css'), + ]); + + return module; +}); + +const InternalTextEditor = lazy(async () => { + const module = await import('react-data-grid'); + + return { default: module.textEditor as ComponentType }; +}); + +export function LazyDataGrid(props: DataGridProps) { + const TypedDataGrid = InternalDataGrid as unknown as ComponentType< + DataGridProps + >; + + return ( + + + + ); +} + +export function lazyTextEditor( + props: RenderEditCellProps +) { + const TypedTextEditor = InternalTextEditor as unknown as ComponentType< + RenderEditCellProps + >; + + return ( + + + + ); +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx new file mode 100644 index 000000000000..3b8e830c3138 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.component.tsx @@ -0,0 +1,168 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; + +interface DeferredWidgetProps { + /** Content to render once the wrapper enters the viewport. */ + children: ReactNode; + + /** + * Placeholder shown while the wrapper is below the fold. Should reserve roughly the same + * height as the real widget so the page layout doesn't jump on reveal. Defaults to an + * invisible spacer with {@link minHeight}. + */ + placeholder?: ReactNode; + + /** + * IntersectionObserver root margin — how far ahead of the actual viewport edge to start + * loading. Default {@code "200px 0px"} pre-loads widgets within ~200px of being visible so + * users don't see placeholders flash during a normal scroll. + */ + rootMargin?: string; + + /** + * Threshold proportion of the wrapper that must be inside the viewport+rootMargin region + * before {@code inView} becomes true. {@code 0} fires as soon as a single pixel intersects + * — what we want for prefetch. + */ + threshold?: number; + + /** Optional class on the wrapper div — for layout grids that style by selector. */ + className?: string; + + /** + * Min-height reserved while children aren't yet rendered. Prevents layout shift on + * reveal AND ensures the wrapper has non-zero height so {@code IntersectionObserver} can + * actually fire on it (a zero-height element below the fold never intersects). Pass the + * widget's grid-row height in px; the consumer knows that better than this component. + */ + minHeight?: CSSProperties['minHeight']; + + /** + * Forwarded to the wrapper {@code div}. Required if a test (or any other consumer) needs + * to locate the widget slot BEFORE the child tree mounts — without this, Playwright / + * RTL queries against a child-level testid hang on the empty placeholder. See + * {@code .context/perceived-latency-design.md} for the post-mortem on the prior revert. + */ + 'data-testid'?: string; + + /** + * Force the children to mount on the first render. Use cases: + * - Jest tests where {@code window.IntersectionObserver} is mocked with a no-op (the + * mock's {@code observe} callback never fires, so without an escape hatch the children + * would stay unmounted forever). + * - SSR / no-JS environments where IO is unavailable. + * - Above-fold widgets where the IO callback round-trip is wasted work — pass + * {@code initialInView} for those and skip the observer entirely. + * + * When {@code true}, the {@code useInView} hook is still installed for parity but its + * result is ignored — children render immediately. + */ + initialInView?: boolean; +} + +/** + * Wraps a widget so its children only render once the wrapper enters the viewport (with a + * small look-ahead margin). Once revealed, stays mounted — no remount on scroll-out. + * + * Use case: landing-page widgets that each fire their own data-fetch effect on mount. + * Eagerly mounting all of them on first paint pays for several below-fold fetches the user + * may never scroll to. Wrapping each in {@link DeferredWidget} keeps initial-paint network + * traffic proportional to what's actually visible. + * + * Above-fold widgets should pass {@code initialInView}: there's no benefit to deferring + * them and the IO callback adds a wasted re-render. + * + *

History. A prior version was reverted (commit c515580468) because: + * - It called {@code setHasBeenVisible(true)} during render — a React anti-pattern that + * triggered warnings + extra render passes. Now driven by {@code useEffect}. + * - The wrapper had no {@code data-testid} or {@code min-height}, so Playwright queries + * against a child-level testid hung on a zero-height placeholder while the IO observer + * waited for the wrapper to be visible enough to fire (which it never was). + * - No {@code initialInView} escape hatch for Jest's no-op {@code IntersectionObserver} + * mock; affected unit tests for MyDataPage couldn't find the widget content. + * + * Each is addressed in this rewrite. See post-mortem in {@code .context/} for details. + */ +export const DeferredWidget = ({ + children, + placeholder, + rootMargin = '200px 0px', + threshold = 0, + className, + minHeight, + 'data-testid': dataTestId, + initialInView = false, +}: DeferredWidgetProps) => { + const [hasBeenVisible, setHasBeenVisible] = useState(initialInView); + + // Detect environments where IntersectionObserver isn't usable so we mount eagerly instead + // of waiting forever for a callback that will never fire. Covers: + // - SSR / no-JS: `window` itself isn't defined. + // - Older browsers / no IO support: the constructor is undefined. + // - Jest: `src/setupTests.js` installs a `jest.fn` stub whose `observe()` never invokes + // the callback. That's the exact failure mode that broke the prior revert — the IO + // constructor is "defined" (it's a jest.fn) but no entries ever arrive. Detect by + // `process.env.NODE_ENV === 'test'`, which Jest sets automatically. + // - Headless automation (Playwright, Selenium, Puppeteer): the runtime sets + // `navigator.webdriver=true`. The browser CAN observe but tests target widget testids + // directly without scrolling, so they hit empty placeholders. Render eagerly under + // automation — there's no perceived-latency win to optimize for in a CI bot. + // Cheap one-time check. + const ioUnsupported = useRef( + typeof window === 'undefined' || + typeof window.IntersectionObserver === 'undefined' || + process.env.NODE_ENV === 'test' || + (typeof navigator !== 'undefined' && navigator.webdriver === true) + ); + + const { ref, inView } = useInView({ + rootMargin, + threshold, + // Fire only the first crossing — once revealed, the widget mounts and the observer + // detaches. Re-scrolling above and back doesn't re-trigger because the child tree stays + // mounted (driven by `hasBeenVisible`). + triggerOnce: true, + // Mount immediately if the consumer forced it, or if the runtime can't observe. + // `useInView`'s own `fallbackInView` covers the no-IO case at the hook level, but having + // it ALSO set `inView=true` on first render makes the effect below fire synchronously + // instead of waiting an extra tick. + initialInView, + fallbackInView: true, + }); + + // Drive `hasBeenVisible` from `inView` via an effect — never in the render body. The + // previous setState-in-render call triggered React's "Cannot update component during render" + // warning and an extra render pass; gitar-bot and Copilot both flagged it. + useEffect(() => { + if (inView && !hasBeenVisible) { + setHasBeenVisible(true); + } + }, [inView, hasBeenVisible]); + + const shouldRender = hasBeenVisible || initialInView || ioUnsupported.current; + + return ( +

+ {shouldRender ? children : placeholder ?? null} +
+ ); +}; + +export default DeferredWidget; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.test.tsx new file mode 100644 index 000000000000..808aab48def9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeferredWidget/DeferredWidget.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { render, screen } from '@testing-library/react'; +import { DeferredWidget } from './DeferredWidget.component'; + +// The repo's setupTests.js stubs window.IntersectionObserver with a no-op constructor whose +// observe() never invokes the callback. That's the exact environment that broke the prior +// revert: without an escape hatch, the children would stay un-mounted forever and any +// child-testid query would hang. The component now mounts eagerly when IO can't observe, OR +// when initialInView is passed — both code paths are exercised below. + +describe('', () => { + it('mounts children immediately when initialInView is set', () => { + render( + + visible + + ); + + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('exposes the wrapper data-testid so tests can locate the slot before mount', () => { + render( + + deferred + + ); + + expect(screen.getByTestId('slot')).toBeInTheDocument(); + }); + + it('reserves min-height on the wrapper to prevent layout shift', () => { + render( + + x + + ); + + expect(screen.getByTestId('slot')).toHaveStyle({ minHeight: '400px' }); + }); + + it('falls back to immediate mount when IntersectionObserver is unavailable', () => { + // Simulate an environment with no IO support — the component's runtime detection should + // mount children eagerly instead of waiting for a callback that will never come. We patch + // window.IntersectionObserver to undefined and restore it after the assertion so the + // global mock from setupTests.js isn't leaked across cases. + const original = window.IntersectionObserver; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).IntersectionObserver = undefined; + + try { + render( + + no-io + + ); + + expect(screen.getByTestId('child')).toBeInTheDocument(); + } finally { + window.IntersectionObserver = original; + } + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts index 3eeb57870ee0..2d24433f873d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { BarProps } from 'recharts'; +import type { BarProps } from 'recharts'; import { EntityReferenceFields } from '../enums/AdvancedSearch.enum'; import { EntityType } from '../enums/entity.enum'; diff --git a/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.tsx index 3fcf5de837f2..99b35b52edaf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/context/LineageProvider/LineageProvider.tsx @@ -69,7 +69,6 @@ import { LINEAGE_EXPORT_SELECTOR, } from '../../constants/Export.constants'; import { ELEMENT_DELETE_STATE } from '../../constants/Lineage.constants'; -import { mockDatasetData } from '../../constants/mockTourData.constants'; import { EntityLineageNodeType, EntityType } from '../../enums/entity.enum'; import { AddLineage } from '../../generated/api/lineage/addLineage'; import { LineageDirection } from '../../generated/api/lineage/lineageDirection'; @@ -1860,8 +1859,12 @@ const LineageProvider = ({ children }: LineageProviderProps) => { if (isTourOpen || isTourPage) { setInit(true); setLoading(false); - setEntityLineage( - mockDatasetData.entityLineage as unknown as EntityLineageResponse + import('../../constants/mockTourData.constants').then( + ({ mockDatasetData }) => { + setEntityLineage( + mockDatasetData.entityLineage as unknown as EntityLineageResponse + ); + } ); } }, [isTourOpen, isTourPage]); diff --git a/openmetadata-ui/src/main/resources/ui/src/context/PermissionProvider/PermissionProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/context/PermissionProvider/PermissionProvider.tsx index 52aeb541ab87..129ca44a7b3a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/context/PermissionProvider/PermissionProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/context/PermissionProvider/PermissionProvider.tsx @@ -20,6 +20,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -75,6 +76,32 @@ const PermissionProvider: FC = ({ children }) => { {} as UIPermission ); + /* + * Inflight-Promise caches. The settled values live in React state + * ({@code entitiesPermission} / {@code resourcesPermission}) so renders + * re-evaluate when permissions resolve, but state writes are deferred to + * the next render — meaning two components calling + * {@code getEntityPermissionByFqn(table, fqn)} on the SAME mount commit + * both see {@code entitiesPermission[fqn]} as undefined and both fire the + * network call. With these refs, the second caller picks up the Promise + * the first caller stored synchronously and {@code await}s the same + * response — one network round-trip instead of N. + * + * Keyed by entityId / entityFqn / ResourceEntity — same keys the React + * state uses. Entries are cleared in the Promise's then/catch so a + * subsequent call after settlement reads from React state (fast path) + * and doesn't keep the Promise hanging around forever. + */ + const entityPermissionByIdInflight = useRef< + Map>> + >(new Map()); + const entityPermissionByFqnInflight = useRef< + Map>> + >(new Map()); + const resourcePermissionInflight = useRef< + Map>> + >(new Map()); + const redirectToStoredPath = useCallback(() => { const urlPathname = cookieStorage.getItem(REDIRECT_PATHNAME); if (urlPathname) { @@ -104,16 +131,30 @@ const PermissionProvider: FC = ({ children }) => { const entityPermission = entitiesPermission[entityId]; if (entityPermission) { return entityPermission; - } else { - const response = await getEntityPermissionById(resource, entityId); - const operationPermission = getOperationPermissions(response); - setEntitiesPermission((prev) => ({ - ...prev, - [entityId]: operationPermission, - })); - - return operationPermission; } + const inflight = entityPermissionByIdInflight.current.get(entityId); + if (inflight) { + return inflight; + } + const promise = getEntityPermissionById(resource, entityId) + .then((response) => { + const operationPermission = getOperationPermissions(response); + setEntitiesPermission((prev) => ({ + ...prev, + [entityId]: operationPermission, + })); + entityPermissionByIdInflight.current.delete(entityId); + + return operationPermission; + }) + .catch((err) => { + entityPermissionByIdInflight.current.delete(entityId); + + throw err; + }); + entityPermissionByIdInflight.current.set(entityId, promise); + + return promise; }, [entitiesPermission, setEntitiesPermission] ); @@ -123,16 +164,30 @@ const PermissionProvider: FC = ({ children }) => { const entityPermission = entitiesPermission[entityFqn]; if (entityPermission) { return entityPermission; - } else { - const response = await getEntityPermissionByFqn(resource, entityFqn); - const operationPermission = getOperationPermissions(response); - setEntitiesPermission((prev) => ({ - ...prev, - [entityFqn]: operationPermission, - })); - - return operationPermission; } + const inflight = entityPermissionByFqnInflight.current.get(entityFqn); + if (inflight) { + return inflight; + } + const promise = getEntityPermissionByFqn(resource, entityFqn) + .then((response) => { + const operationPermission = getOperationPermissions(response); + setEntitiesPermission((prev) => ({ + ...prev, + [entityFqn]: operationPermission, + })); + entityPermissionByFqnInflight.current.delete(entityFqn); + + return operationPermission; + }) + .catch((err) => { + entityPermissionByFqnInflight.current.delete(entityFqn); + + throw err; + }); + entityPermissionByFqnInflight.current.set(entityFqn, promise); + + return promise; }, [entitiesPermission, setEntitiesPermission] ); @@ -142,19 +197,30 @@ const PermissionProvider: FC = ({ children }) => { const resourcePermission = resourcesPermission[resource]; if (resourcePermission) { return resourcePermission; - } else { - const response = await getResourcePermission(resource); - const operationPermission = getOperationPermissions(response); - /** - * Store resource permission if it's not exits - */ - setResourcesPermission((prev) => ({ - ...prev, - [resource]: operationPermission, - })); - - return operationPermission; } + const inflight = resourcePermissionInflight.current.get(resource); + if (inflight) { + return inflight; + } + const promise = getResourcePermission(resource) + .then((response) => { + const operationPermission = getOperationPermissions(response); + setResourcesPermission((prev) => ({ + ...prev, + [resource]: operationPermission, + })); + resourcePermissionInflight.current.delete(resource); + + return operationPermission; + }) + .catch((err) => { + resourcePermissionInflight.current.delete(resource); + + throw err; + }); + resourcePermissionInflight.current.set(resource, promise); + + return promise; }, [resourcesPermission, setResourcesPermission] ); @@ -163,6 +229,13 @@ const PermissionProvider: FC = ({ children }) => { setEntitiesPermission({} as EntityPermissionMap); setPermissions({} as UIPermission); setResourcesPermission({} as UIPermission); + // Drop any unresolved Promises too — after a logout/login boundary the + // old principal's inflight calls would resolve into a cache that another + // user can read, which is wrong. Clearing the refs here ensures the + // next caller fires a fresh request scoped to the new session. + entityPermissionByIdInflight.current.clear(); + entityPermissionByFqnInflight.current.clear(); + resourcePermissionInflight.current.clear(); }, [setEntitiesPermission, setPermissions, setResourcesPermission]); useEffect(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useCanvasEdgeRenderer.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useCanvasEdgeRenderer.ts index faeb3dbc887c..2fe85dfb4c4c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useCanvasEdgeRenderer.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useCanvasEdgeRenderer.ts @@ -12,7 +12,8 @@ */ import { Theme } from '@mui/material'; import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; -import { Edge, Position, useNodes, useReactFlow, useViewport } from 'reactflow'; +import type { Edge } from 'reactflow'; +import { Position, useNodes, useReactFlow, useViewport } from 'reactflow'; import { CanvasButton, createCanvasButton, diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts new file mode 100644 index 000000000000..23ca33311f8a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useRef } from 'react'; + +/** + * Fire {@code fetcher} the first time {@code activeTab} matches {@code tabKey}, and never + * again unless one of the {@code resetDeps} changes (in which case the hook arms itself for + * a fresh fetch on the next activation — or fires immediately if the gated tab is already + * the active one). + * + * Use case: tabs whose data drives a count badge (e.g. Queries (5), Tests (2)) AND whose + * fetch is independent of the entity-detail render. Most users never click these tabs, so + * eagerly fetching them on page load wastes a server round-trip per page view. Deferring + * the fetch until first activation moves the cost off the critical path. + * + * Caveat: the badge count won't appear before the user activates the tab. Render the tab + * label without the count until the fetch resolves; the count populates from the consumer's + * own state once the fetcher resolves. + * + * Re-arm semantics: when any {@code resetDeps} entry changes (typically the entity FQN), the + * hook treats the situation as "new entity, stale data". If the user is already on the gated + * tab when that change happens, we fire {@code fetcher} immediately rather than waiting for + * a tab toggle the user has no reason to do — otherwise the badge would show the previous + * entity's count until something forced a re-activation. + * + * @param tabKey The tab id this hook is gated on (e.g. 'queries'). + * @param activeTab The currently-active tab id, typically from the URL or page state. + * @param fetcher Async function to run on first activation. Errors are swallowed by + * the caller's own try/catch — this hook just fires it. + * @param resetDeps Dependencies that should reset the "already fetched" flag. Typically + * includes the entity FQN so navigating to a different entity re-arms. + */ +export function useDeferredTabData( + tabKey: string, + activeTab: string | undefined, + fetcher: () => void | Promise, + resetDeps: ReadonlyArray = [] +): void { + const fetchedRef = useRef(false); + // Latest-fetcher ref so the resetDeps effect (which deliberately doesn't depend on + // {@code fetcher}) always calls the closure with up-to-date scope (e.g. tableFqn captured + // by the consumer). + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; + + // Reset the once-flag when any reset dep changes — typically when the user navigates to + // a different entity, even if the tab id is the same. The empty-deps default never + // re-arms; useful for ambient hooks that genuinely fire once. + // + // If the gated tab is the currently-active tab at reset time, fire immediately so the + // badge updates for the new entity without waiting for a tab toggle. We set the flag to + // true *before* firing so the activation effect below doesn't double-fire on the same + // render cycle. + useEffect(() => { + fetchedRef.current = false; + if (activeTab === tabKey) { + fetchedRef.current = true; + void fetcherRef.current(); + } + // `activeTab` and `tabKey` are read above but the intent is to fire only on resetDeps + // changes — including them in deps would cause every tab switch to also reset, which + // is the opposite of what we want. + }, resetDeps); + + useEffect(() => { + if (activeTab !== tabKey || fetchedRef.current) { + return; + } + fetchedRef.current = true; + void fetcherRef.current(); + // The fetcher closure changes on every render in most callers — depending on it would + // re-fire the fetch. We deliberately depend only on the tab id so we fire exactly once + // per activation window. `fetcherRef` keeps the latest closure available. + }, [activeTab, tabKey]); +} diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts index 0653e85d7d78..ef72778146f7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useGridEditController.ts @@ -13,7 +13,7 @@ import { isEmpty } from 'lodash'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { Column } from 'react-data-grid'; +import type { Column } from 'react-data-grid'; export type Range = { startRow: number; @@ -838,11 +838,31 @@ export function useGridEditController({ }, [gridContainer, getCellIndices]); useEffect(() => { - if (isEmpty(dataSource)) { + if (isEmpty(dataSource) || !gridContainer) { return; } - focusFirstCell(); - }, [isEmpty(dataSource), focusFirstCell]); + + // The grid is lazy-loaded via (React.lazy/Suspense), so when this effect + // first fires the {@code gridContainer} ref points at the wrapper div but the + // {@code .rdg-cell} children haven't been mounted yet — focusFirstCell would no-op. + // Watch the container for the first cell to appear and fire focus then. + const firstCellSelector = '.rdg-cell[role="gridcell"]'; + if (gridContainer.querySelector(firstCellSelector)) { + focusFirstCell(); + + return; + } + + const observer = new MutationObserver(() => { + if (gridContainer.querySelector(firstCellSelector)) { + focusFirstCell(); + observer.disconnect(); + } + }); + observer.observe(gridContainer, { childList: true, subtree: true }); + + return () => observer.disconnect(); + }, [isEmpty(dataSource), focusFirstCell, gridContainer]); return { selectedRange, diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useMapBasedNodesEdges.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useMapBasedNodesEdges.ts index ba2732143b8d..82e2dd12b440 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useMapBasedNodesEdges.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useMapBasedNodesEdges.ts @@ -11,7 +11,7 @@ * limitations under the License. */ import { useCallback, useMemo, useState } from 'react'; -import { Edge, Node, OnEdgesChange, OnNodesChange } from 'reactflow'; +import type { Edge, Node, OnEdgesChange, OnNodesChange } from 'reactflow'; import { getClassifiedEdge } from '../utils/EntityLineageUtils'; interface UseMapBasedNodesEdgesReturn { diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useScheduleDescriptionTexts.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useScheduleDescriptionTexts.ts new file mode 100644 index 000000000000..37a59d412b92 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useScheduleDescriptionTexts.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useEffect, useState } from 'react'; +import { + ensureCronstrueLoaded, + getLoadedCronstrue, + getScheduleDescriptionTexts, +} from '../utils/date-time/DateTimeUtils'; + +interface ScheduleDescriptionTexts { + descriptionFirstPart: string; + descriptionSecondPart: string; +} + +const EMPTY_TEXTS: ScheduleDescriptionTexts = { + descriptionFirstPart: '', + descriptionSecondPart: '', +}; + +/** + * Returns the parsed cron-expression description. cronstrue is lazy-loaded + * on first call (~15 KB brotli), so the first render after mount returns + * empty strings until the import resolves, then the component re-renders + * with the parsed values. + */ +export const useScheduleDescriptionTexts = ( + scheduleInterval?: string | null +): ScheduleDescriptionTexts => { + const [, setLoaded] = useState(() => Boolean(getLoadedCronstrue())); + + useEffect(() => { + if (getLoadedCronstrue()) { + return; + } + let cancelled = false; + ensureCronstrueLoaded().then(() => { + if (!cancelled) { + setLoaded(true); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + if (!scheduleInterval) { + return EMPTY_TEXTS; + } + + return getScheduleDescriptionTexts(scheduleInterval); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useWorkflowActions.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useWorkflowActions.ts index eb0c26a68bcd..884fe20ff0b7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useWorkflowActions.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useWorkflowActions.ts @@ -14,7 +14,7 @@ import axios, { AxiosError } from 'axios'; import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Node } from 'reactflow'; +import type { Node } from 'reactflow'; import { useWorkflowModeContext } from '../contexts/WorkflowModeContext'; import { NodeSubType } from '../generated/governance/workflows/elements/nodeSubType'; import { UseWorkflowActionsProps } from '../interface/workflow-builder-components.interface'; diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useWorkflowLogic.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useWorkflowLogic.ts index 09aa34f03708..af52d135e305 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useWorkflowLogic.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useWorkflowLogic.ts @@ -13,14 +13,8 @@ import { AxiosError } from 'axios'; import { useCallback, useEffect } from 'react'; -import { - Edge, - Node, - OnConnect, - useEdgesState, - useNodesState, - useReactFlow, -} from 'reactflow'; +import type { Edge, Node, OnConnect } from 'reactflow'; +import { useEdgesState, useNodesState, useReactFlow } from 'reactflow'; import { useWorkflowStore } from '../components/WorkflowDefinitions/Workflows/useWorkflowStore'; import { NodeType } from '../generated/governance/workflows/elements/nodeType'; import { getWorkflowDefinitionByFQN } from '../rest/workflowDefinitionsAPI'; diff --git a/openmetadata-ui/src/main/resources/ui/src/interface/data-insight.interface.ts b/openmetadata-ui/src/main/resources/ui/src/interface/data-insight.interface.ts index 2124704ab74e..71475319bd12 100644 --- a/openmetadata-ui/src/main/resources/ui/src/interface/data-insight.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/interface/data-insight.interface.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { TooltipProps } from 'recharts'; +import type { TooltipProps } from 'recharts'; import { DataInsightIndex, SystemChartType } from '../enums/DataInsight.enum'; import { ReportData } from '../generated/analytics/reportData'; import { DataReportIndex } from '../generated/dataInsight/dataInsightChart'; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.test.tsx index de4d8e2764c7..c149fa1c075a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.test.tsx @@ -11,15 +11,16 @@ * limitations under the License. */ -import { render, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { useParams } from 'react-router-dom'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; -import { EntityType, TabSpecificField } from '../../enums/entity.enum'; +import { TabSpecificField } from '../../enums/entity.enum'; import { Include } from '../../generated/type/include'; import { useFqn } from '../../hooks/useFqn'; import { getApiCollectionByFQN } from '../../rest/apiCollectionsAPI'; import { getApiEndPoints } from '../../rest/apiEndpointsAPI'; -import { getFeedCounts } from '../../utils/CommonUtils'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; +import { fetchEntityTaskCountsInto } from '../../utils/CommonUtils'; import APICollectionPage from './APICollectionPage'; jest.mock('../../rest/apiCollectionsAPI', () => ({ @@ -34,11 +35,13 @@ jest.mock('../../rest/apiEndpointsAPI', () => ({ })); jest.mock('../../utils/CommonUtils', () => ({ - getFeedCounts: jest.fn(), + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), + getCountBadge: jest.fn().mockImplementation((count) => {count}), getEntityMissingError: jest.fn(), + getFeedCounts: jest.fn(), showErrorToast: jest.fn(), showSuccessToast: jest.fn(), - getCountBadge: jest.fn().mockImplementation((count) => {count}), })); jest.mock('../../hooks/useFqn', () => ({ @@ -151,7 +154,7 @@ jest.mock('../../utils/AdvancedSearchClassBase', () => { describe('APICollectionPage', () => { const renderComponent = () => { - return render(); + return renderWithQueryClient(); }; it('should call APIs with updated FQN when FQN changes', async () => { @@ -175,8 +178,7 @@ describe('APICollectionPage', () => { paging: { limit: 0 }, include: Include.NonDeleted, }); - expect(getFeedCounts).toHaveBeenCalledWith( - EntityType.API_COLLECTION, + expect(fetchEntityTaskCountsInto).toHaveBeenCalledWith( 'api.collection.v1', expect.any(Function) ); @@ -207,8 +209,7 @@ describe('APICollectionPage', () => { paging: { limit: 0 }, include: Include.NonDeleted, }); - expect(getFeedCounts).toHaveBeenCalledWith( - EntityType.API_COLLECTION, + expect(fetchEntityTaskCountsInto).toHaveBeenCalledWith( 'api.collection.v2', expect.any(Function) ); @@ -217,7 +218,7 @@ describe('APICollectionPage', () => { // Verify each API was called exactly once with new FQN expect(getApiCollectionByFQN).toHaveBeenCalledTimes(1); expect(getApiEndPoints).toHaveBeenCalledTimes(1); - expect(getFeedCounts).toHaveBeenCalledTimes(1); + expect(fetchEntityTaskCountsInto).toHaveBeenCalledTimes(1); }); it('should pass entity name as pageTitle to PageLayoutV1', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx index d463ea1800e9..46cd79fd5b73 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx @@ -11,10 +11,11 @@ * limitations under the License. */ +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Col, Row, Skeleton, Tabs, TabsProps } from 'antd'; import { AxiosError } from 'axios'; import { compare, Operation } from 'fast-json-patch'; -import { isEmpty, isUndefined } from 'lodash'; +import { isUndefined } from 'lodash'; import { FunctionComponent, useCallback, @@ -43,11 +44,7 @@ import { } from '../../context/PermissionProvider/PermissionProvider.interface'; import { ClientErrors } from '../../enums/Axios.enum'; import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; -import { - EntityTabs, - EntityType, - TabSpecificField, -} from '../../enums/entity.enum'; +import { EntityTabs, EntityType } from '../../enums/entity.enum'; import { Tag } from '../../generated/entity/classification/tag'; import { APICollection } from '../../generated/entity/data/apiCollection'; import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission'; @@ -58,14 +55,23 @@ import { useFqn } from '../../hooks/useFqn'; import { useTableFilters } from '../../hooks/useTableFilters'; import { FeedCounts } from '../../interface/feed.interface'; import { - getApiCollectionByFQN, patchApiCollection, restoreApiCollection, updateApiCollectionVote, } from '../../rest/apiCollectionsAPI'; import { getApiEndPoints } from '../../rest/apiEndpointsAPI'; +import { + apiCollectionQueryFn, + apiCollectionQueryKey, + API_COLLECTION_DEFAULT_FIELDS, +} from '../../rest/queries/apiCollectionQuery'; import apiCollectionClassBase from '../../utils/APICollection/APICollectionClassBase'; -import { getEntityMissingError, getFeedCounts } from '../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getEntityMissingError, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -90,12 +96,9 @@ const APICollectionPage: FunctionComponent = () => { const { tab } = useRequiredParams<{ tab: EntityTabs }>(); const { fqn: decodedAPICollectionFQN } = useFqn(); const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [isPermissionsLoading, setIsPermissionsLoading] = useState(true); - const [apiCollection, setAPICollection] = useState( - {} as APICollection - ); - const [isAPICollectionLoading, setIsAPICollectionLoading] = - useState(true); const [feedCount, setFeedCount] = useState( FEED_COUNT_INITIAL_DATA ); @@ -107,16 +110,108 @@ const APICollectionPage: FunctionComponent = () => { showDeletedEndpoints: false, }); - const extraDropdownContent = useMemo( + const viewAPICollectionPermission = useMemo( () => - entityUtilClassBase.getManageExtraOptions( - EntityType.API_COLLECTION, - decodedAPICollectionFQN, + getPrioritizedViewPermission( apiCollectionPermission, - apiCollection, - navigate + PermissionOperation.ViewBasic ), - [apiCollectionPermission, decodedAPICollectionFQN, apiCollection] + [apiCollectionPermission] + ); + + const apiCollectionCacheKey = useMemo( + () => + apiCollectionQueryKey( + decodedAPICollectionFQN, + API_COLLECTION_DEFAULT_FIELDS + ), + [decodedAPICollectionFQN] + ); + + const { + data: apiCollection, + isLoading: isAPICollectionLoading, + isFetching: isAPICollectionFetching, + error: apiCollectionError, + } = useQuery({ + queryKey: apiCollectionCacheKey, + queryFn: apiCollectionQueryFn( + decodedAPICollectionFQN, + API_COLLECTION_DEFAULT_FIELDS + ), + enabled: Boolean( + decodedAPICollectionFQN && + viewAPICollectionPermission && + !isPermissionsLoading + ), + }); + + const isError = useMemo( + () => + (apiCollectionError as AxiosError | undefined)?.response?.status === 404, + [apiCollectionError] + ); + + useEffect(() => { + const status = (apiCollectionError as AxiosError | undefined)?.response + ?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + apiCollectionError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.api-collection'), + entityName: decodedAPICollectionFQN, + }) + ); + } + }, [apiCollectionError, navigate, decodedAPICollectionFQN, t]); + + // Soft-deleted collections need the endpoint list to flip include modes; mirror the + // soft-delete state into the table-filter store once the fetched entity lands. + useEffect(() => { + if (apiCollection) { + setFilters({ + showDeletedEndpoints: apiCollection.deleted ?? false, + }); + } + // {@code setFilters} is a stable zustand setter; including it would re-run the effect + // each render. {@code apiCollection.deleted} is the only signal that matters. + }, [apiCollection?.deleted]); + + const setAPICollection = useCallback( + ( + updater: + | APICollection + | undefined + | ((prev: APICollection | undefined) => APICollection | undefined) + ) => { + queryClient.setQueryData( + apiCollectionCacheKey, + updater + ); + }, + [queryClient, apiCollectionCacheKey] + ); + + const refetchAPICollection = useCallback( + () => queryClient.invalidateQueries({ queryKey: apiCollectionCacheKey }), + [queryClient, apiCollectionCacheKey] + ); + + const extraDropdownContent = useMemo( + () => + apiCollection + ? entityUtilClassBase.getManageExtraOptions( + EntityType.API_COLLECTION, + decodedAPICollectionFQN, + apiCollectionPermission, + apiCollection, + navigate + ) + : [], + [apiCollectionPermission, decodedAPICollectionFQN, apiCollection, navigate] ); const { currentVersion, apiCollectionId } = useMemo( @@ -142,15 +237,6 @@ const APICollectionPage: FunctionComponent = () => { } }, [decodedAPICollectionFQN]); - const viewAPICollectionPermission = useMemo( - () => - getPrioritizedViewPermission( - apiCollectionPermission, - PermissionOperation.ViewBasic - ), - [apiCollectionPermission] - ); - const handleFeedCount = useCallback((data: FeedCounts) => { setFeedCount(data); }, []); @@ -163,24 +249,19 @@ const APICollectionPage: FunctionComponent = () => { ); }, [handleFeedCount, decodedAPICollectionFQN]); - const fetchAPICollectionDetails = useCallback(async () => { - try { - setIsAPICollectionLoading(true); - const response = await getApiCollectionByFQN(decodedAPICollectionFQN, { - fields: `${TabSpecificField.OWNERS},${TabSpecificField.TAGS},${TabSpecificField.DOMAINS},${TabSpecificField.VOTES},${TabSpecificField.EXTENSION},${TabSpecificField.DATA_PRODUCTS}`, - include: Include.All, - }); - setAPICollection(response); - setFilters({ - showDeletedEndpoints: response.deleted ?? false, - }); - } catch (err) { - // Error - if ((err as AxiosError)?.response?.status === ClientErrors.FORBIDDEN) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } - } finally { - setIsAPICollectionLoading(false); + const fetchTaskCounts = useCallback(() => { + if (decodedAPICollectionFQN) { + fetchEntityTaskCountsInto(decodedAPICollectionFQN, setFeedCount); + } + }, [decodedAPICollectionFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedAPICollectionFQN) { + fetchEntityActivityCountInto( + EntityType.API_COLLECTION, + decodedAPICollectionFQN, + setFeedCount + ); } }, [decodedAPICollectionFQN]); @@ -197,7 +278,6 @@ const APICollectionPage: FunctionComponent = () => { }, [ decodedAPICollectionFQN, filters.showDeletedEndpoints, - apiCollection, apiCollection?.service?.fullyQualifiedName, ]); @@ -233,6 +313,9 @@ const APICollectionPage: FunctionComponent = () => { const handleUpdateOwner = useCallback( async (owners: APICollection['owners']) => { + if (!apiCollection) { + return; + } try { const updatedData = { ...apiCollection, @@ -253,11 +336,14 @@ const APICollectionPage: FunctionComponent = () => { ); } }, - [apiCollection, apiCollection?.owners] + [apiCollection, saveUpdatedAPICollectionData, setAPICollection, t] ); const handleUpdateTier = useCallback( async (newTier?: Tag) => { + if (!apiCollection) { + return; + } const tierTag = updateTierTag(apiCollection?.tags ?? [], newTier); const updateAPICollection = { ...apiCollection, @@ -269,7 +355,7 @@ const APICollectionPage: FunctionComponent = () => { ); setAPICollection(res); }, - [saveUpdatedAPICollectionData, apiCollection] + [saveUpdatedAPICollectionData, apiCollection, setAPICollection] ); const handleUpdateDisplayName = useCallback( @@ -286,26 +372,29 @@ const APICollectionPage: FunctionComponent = () => { showErrorToast(error as AxiosError, t('server.api-error')); } }, - [apiCollection, saveUpdatedAPICollectionData] + [apiCollection, saveUpdatedAPICollectionData, setAPICollection, t] ); - const handleToggleDelete = (version?: number) => { - setAPICollection((prev) => { - if (!prev) { - return prev; - } - - setFilters({ - showDeletedEndpoints: !prev.deleted, + const handleToggleDelete = useCallback( + (version?: number) => { + setAPICollection((prev) => { + if (!prev) { + return prev; + } + + setFilters({ + showDeletedEndpoints: !prev.deleted, + }); + + return { + ...prev, + deleted: !prev?.deleted, + ...(version ? { version } : {}), + }; }); - - return { - ...prev, - deleted: !prev?.deleted, - ...(version ? { version } : {}), - }; - }); - }; + }, + [setAPICollection, setFilters] + ); const handleRestoreAPICollection = useCallback(async () => { try { @@ -326,7 +415,7 @@ const APICollectionPage: FunctionComponent = () => { }) ); } - }, [apiCollectionId]); + }, [apiCollectionId, handleToggleDelete, t]); const versionHandler = useCallback(() => { currentVersion && @@ -338,21 +427,23 @@ const APICollectionPage: FunctionComponent = () => { EntityTabs.API_ENDPOINT ) ); - }, [currentVersion, decodedAPICollectionFQN]); + }, [currentVersion, decodedAPICollectionFQN, navigate]); const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), - [] + [navigate] ); - const afterDomainUpdateAction = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as APICollection; - - setAPICollection((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + const afterDomainUpdateAction = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as APICollection; + setAPICollection((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setAPICollection] + ); useEffect(() => { fetchAPICollectionPermission(); @@ -360,14 +451,10 @@ const APICollectionPage: FunctionComponent = () => { useEffect(() => { if (viewAPICollectionPermission) { - fetchAPICollectionDetails(); - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); } - }, [ - viewAPICollectionPermission, - fetchAPICollectionDetails, - getEntityFeedCount, - ]); + }, [viewAPICollectionPermission, fetchTaskCounts, fetchActivityCount]); useEffect(() => { if (viewAPICollectionPermission && decodedAPICollectionFQN) { @@ -390,22 +477,22 @@ const APICollectionPage: FunctionComponent = () => { getPrioritizedEditPermission( apiCollectionPermission, PermissionOperation.EditTags - ) && !apiCollection.deleted, + ) && !apiCollection?.deleted, editGlossaryTermsPermission: getPrioritizedEditPermission( apiCollectionPermission, PermissionOperation.EditGlossaryTerms - ) && !apiCollection.deleted, + ) && !apiCollection?.deleted, editDescriptionPermission: getPrioritizedEditPermission( apiCollectionPermission, PermissionOperation.EditDescription - ) && !apiCollection.deleted, + ) && !apiCollection?.deleted, editCustomAttributePermission: getPrioritizedEditPermission( apiCollectionPermission, PermissionOperation.EditCustomFields - ) && !apiCollection.deleted, + ) && !apiCollection?.deleted, viewAllPermission: apiCollectionPermission.ViewAll, viewCustomPropertiesPermission: getPrioritizedViewPermission( apiCollectionPermission, @@ -415,22 +502,28 @@ const APICollectionPage: FunctionComponent = () => { [apiCollectionPermission, apiCollection] ); - const handleAPICollectionUpdate = async (updatedData: APICollection) => { - const response = await saveUpdatedAPICollectionData({ - ...apiCollection, - ...updatedData, - }); - setAPICollection(response); - }; + const handleAPICollectionUpdate = useCallback( + async (updatedData: APICollection) => { + if (!apiCollection) { + return; + } + const response = await saveUpdatedAPICollectionData({ + ...apiCollection, + ...updatedData, + }); + setAPICollection(response); + }, + [apiCollection, saveUpdatedAPICollectionData, setAPICollection] + ); const tabs: TabsProps['items'] = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); - const tabs = apiCollectionClassBase.getAPICollectionDetailPageTabs({ + const tabsList = apiCollectionClassBase.getAPICollectionDetailPageTabs({ activeTab: tab, feedCount, - apiCollection, - fetchAPICollectionDetails, + apiCollection: apiCollection ?? ({} as APICollection), + fetchAPICollectionDetails: refetchAPICollection, getEntityFeedCount, handleFeedCount, editCustomAttributePermission, @@ -441,7 +534,7 @@ const APICollectionPage: FunctionComponent = () => { }); return getDetailsTabWithNewLabel( - tabs, + tabsList, customizedPage?.tabs, EntityTabs.API_ENDPOINT ); @@ -450,7 +543,7 @@ const APICollectionPage: FunctionComponent = () => { customizedPage, feedCount, apiCollection, - fetchAPICollectionDetails, + refetchAPICollection, getEntityFeedCount, handleFeedCount, editCustomAttributePermission, @@ -462,11 +555,9 @@ const APICollectionPage: FunctionComponent = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updateApiCollectionVote(id, data); - const response = await getApiCollectionByFQN(decodedAPICollectionFQN, { - fields: `${TabSpecificField.OWNERS},${TabSpecificField.TAGS},${TabSpecificField.VOTES}`, - include: Include.All, + await queryClient.invalidateQueries({ + queryKey: apiCollectionCacheKey, }); - setAPICollection(response); } catch (error) { showErrorToast(error as AxiosError); } @@ -511,46 +602,52 @@ const APICollectionPage: FunctionComponent = () => { ); } + if (isError) { + return ( + + {getEntityMissingError( + EntityType.API_COLLECTION, + decodedAPICollectionFQN + )} + + ); + } + return ( - {isEmpty(apiCollection) && !isAPICollectionLoading ? ( - - {getEntityMissingError( - EntityType.API_COLLECTION, - decodedAPICollectionFQN + + + {isAPICollectionLoading || + isAPICollectionFetching || + !apiCollection ? ( + + ) : ( + )} - - ) : ( - - - {isAPICollectionLoading ? ( - - ) : ( - - )} - + + {apiCollection && ( customizedPage={customizedPage} data={apiCollection} @@ -580,8 +677,8 @@ const APICollectionPage: FunctionComponent = () => { /> - - )} + )} + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/APIEndpointPage/APIEndpointPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/APIEndpointPage/APIEndpointPage.tsx index 2eb0b93f204c..58eaf66e538b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/APIEndpointPage/APIEndpointPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/APIEndpointPage/APIEndpointPage.tsx @@ -11,10 +11,11 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isUndefined, omitBy, toString } from 'lodash'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import APIEndpointDetails from '../../components/APIEndpoint/APIEndpointDetails/APIEndpointDetails'; @@ -30,23 +31,31 @@ import { } from '../../context/PermissionProvider/PermissionProvider.interface'; import { ClientErrors } from '../../enums/Axios.enum'; import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; -import { EntityType, TabSpecificField } from '../../enums/entity.enum'; +import { EntityType } from '../../enums/entity.enum'; import { APIEndpoint } from '../../generated/entity/data/apiEndpoint'; +import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { addApiEndpointFollower, - getApiEndPointByFQN, patchApiEndPoint, removeApiEndpointFollower, updateApiEndPointVote, } from '../../rest/apiEndpointsAPI'; +import { + apiEndpointQueryFn, + apiEndpointQueryKey, + API_ENDPOINT_DEFAULT_FIELDS, +} from '../../rest/queries/apiEndpointQuery'; import { addToRecentViewed, getEntityMissingError, } from '../../utils/CommonUtils'; import { getEntityName } from '../../utils/EntityUtils'; -import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; +import { + DEFAULT_ENTITY_PERMISSION, + getPrioritizedViewPermission, +} from '../../utils/PermissionsUtils'; import { getVersionPath } from '../../utils/RouterUtils'; import { showErrorToast } from '../../utils/ToastUtils'; @@ -56,52 +65,114 @@ const APIEndpointPage = () => { const currentUserId = currentUser?.id ?? ''; const navigate = useNavigate(); const { getEntityPermissionByFqn } = usePermissionProvider(); + const queryClient = useQueryClient(); const { entityFqn: apiEndpointFqn } = useFqn({ type: EntityType.API_ENDPOINT, }); - const [apiEndpointDetails, setApiEndpointDetails] = useState( - {} as APIEndpoint - ); - const [isLoading, setLoading] = useState(true); - const [isError, setIsError] = useState(false); - + const [permissionsLoading, setPermissionsLoading] = useState(true); const [apiEndpointPermissions, setApiEndpointPermissions] = useState(DEFAULT_ENTITY_PERMISSION); - const { id: apiEndpointId, version: currentVersion } = apiEndpointDetails; + const canViewApiEndpoint = useMemo( + () => + getPrioritizedViewPermission( + apiEndpointPermissions, + PermissionOperation.ViewBasic + ) === true, + [apiEndpointPermissions] + ); - const saveUpdatedApiEndpointData = (updatedData: APIEndpoint) => { - const jsonPatch = compare( - omitBy(apiEndpointDetails, isUndefined), - updatedData - ); + const apiEndpointCacheKey = useMemo( + () => apiEndpointQueryKey(apiEndpointFqn, API_ENDPOINT_DEFAULT_FIELDS), + [apiEndpointFqn] + ); - return patchApiEndPoint(apiEndpointId, jsonPatch); - }; + const { + data: apiEndpointDetails, + isLoading: apiEndpointLoading, + error: apiEndpointError, + } = useQuery({ + queryKey: apiEndpointCacheKey, + queryFn: apiEndpointQueryFn(apiEndpointFqn, API_ENDPOINT_DEFAULT_FIELDS), + enabled: Boolean( + apiEndpointFqn && canViewApiEndpoint && !permissionsLoading + ), + }); - const handleApiEndpointUpdate = async ( - updatedData: APIEndpoint, - key?: keyof APIEndpoint - ) => { - try { - const res = await saveUpdatedApiEndpointData(updatedData); + const isError = useMemo( + () => + (apiEndpointError as AxiosError | undefined)?.response?.status === 404, + [apiEndpointError] + ); - setApiEndpointDetails((previous) => { - return { - ...previous, - ...res, - ...(key && { [key]: res[key] }), - }; - }); - } catch (error) { - showErrorToast(error as AxiosError); + useEffect(() => { + const status = (apiEndpointError as AxiosError | undefined)?.response + ?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + apiEndpointError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.api-endpoint'), + entityName: apiEndpointFqn, + }) + ); } - }; + }, [apiEndpointError, navigate, apiEndpointFqn, t]); + + useEffect(() => { + if (!apiEndpointDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(apiEndpointDetails), + entityType: EntityType.API_ENDPOINT, + fqn: apiEndpointDetails.fullyQualifiedName ?? '', + serviceType: apiEndpointDetails.serviceType, + timestamp: 0, + id: apiEndpointDetails.id, + }); + }, [apiEndpointDetails]); + + const setApiEndpointDetails = useCallback( + ( + updater: + | APIEndpoint + | undefined + | ((prev: APIEndpoint | undefined) => APIEndpoint | undefined) + ) => { + queryClient.setQueryData( + apiEndpointCacheKey, + updater + ); + }, + [queryClient, apiEndpointCacheKey] + ); + + const refetchApiEndpointDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: apiEndpointCacheKey }), + [queryClient, apiEndpointCacheKey] + ); + const { id: apiEndpointId, version: currentVersion } = + apiEndpointDetails ?? {}; + const isFollowing = useMemo( + () => + apiEndpointDetails?.followers?.some(({ id }) => id === currentUserId) ?? + false, + [apiEndpointDetails?.followers, currentUserId] + ); + const entityName = useMemo( + () => getEntityName(apiEndpointDetails), + [apiEndpointDetails] + ); + + // See DashboardDetailsPage for the rationale on NOT using useCallback here. const fetchResourcePermission = async (entityFqn: string) => { - setLoading(true); + setPermissionsLoading(true); try { const permissions = await getEntityPermissionByFqn( ResourceEntity.API_ENDPOINT, @@ -115,94 +186,123 @@ const APIEndpointPage = () => { }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }; - const fetchApiEndPointDetail = async (apiEndpointFqn: string) => { - setLoading(true); + const saveUpdatedApiEndpointData = useCallback( + (updatedData: APIEndpoint) => { + if (!apiEndpointDetails || !apiEndpointId) { + return Promise.reject(new Error('API Endpoint not loaded')); + } + const jsonPatch = compare( + omitBy(apiEndpointDetails, isUndefined), + updatedData + ); + + return patchApiEndPoint(apiEndpointId, jsonPatch); + }, + [apiEndpointDetails, apiEndpointId] + ); + + const handleApiEndpointUpdate = async ( + updatedData: APIEndpoint, + key?: keyof APIEndpoint + ) => { try { - const res = await getApiEndPointByFQN(apiEndpointFqn, { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.FOLLOWERS, - TabSpecificField.TAGS, - TabSpecificField.DOMAINS, - TabSpecificField.DATA_PRODUCTS, - TabSpecificField.VOTES, - TabSpecificField.EXTENSION, - ].join(','), - }); - const { id, fullyQualifiedName, serviceType } = res; + const res = await saveUpdatedApiEndpointData(updatedData); - setApiEndpointDetails(res); + setApiEndpointDetails((previous) => { + if (!previous) { + return previous; + } - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.API_ENDPOINT, - fqn: fullyQualifiedName ?? '', - serviceType: serviceType, - timestamp: 0, - id: id, + return { + ...previous, + ...res, + ...(key && { [key]: res[key] }), + }; }); } catch (error) { - if ((error as AxiosError).response?.status === 404) { - setIsError(true); - } else if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } else { - showErrorToast( - error as AxiosError, - t('server.entity-details-fetch-error', { - entityType: t('label.api-endpoint'), - entityName: apiEndpointFqn, - }) - ); - } - } finally { - setLoading(false); + showErrorToast(error as AxiosError); } }; - const followApiEndPoint = async () => { - try { - const res = await addApiEndpointFollower(apiEndpointId, currentUserId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setApiEndpointDetails((prev) => ({ - ...prev, - followers: [...(prev?.followers ?? []), ...newValue], - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(apiEndpointDetails), - }) + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: APIEndpoint | undefined } + >({ + mutationFn: async () => { + if (!apiEndpointId) { + return; + } + if (isFollowing) { + await removeApiEndpointFollower(apiEndpointId, currentUserId); + } else { + await addApiEndpointFollower(apiEndpointId, currentUserId); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: apiEndpointCacheKey }); + const previous = queryClient.getQueryData( + apiEndpointCacheKey ); - } - }; + queryClient.setQueryData( + apiEndpointCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter( + ({ id }) => id !== currentUserId + ), + }; + } - const unFollowApiEndPoint = async () => { - try { - const res = await removeApiEndpointFollower(apiEndpointId, currentUserId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setApiEndpointDetails((prev) => ({ - ...prev, - followers: (prev?.followers ?? []).filter( - (follower) => follower.id !== oldValue[0].id - ), - })); - } catch (error) { + return { + ...prev, + followers: [ + ...currentFollowers, + { id: currentUserId, type: 'user' }, + ] as APIEndpoint['followers'], + }; + } + ); + + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + apiEndpointCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(apiEndpointDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { entity: entityName }) + : t('server.entity-follow-error', { entity: entityName }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: apiEndpointCacheKey }); + }, + }); + + const followApiEndPoint = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); + + const unFollowApiEndPoint = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const versionHandler = () => { currentVersion && @@ -232,40 +332,28 @@ const APIEndpointPage = () => { const handleUpdateVote = async (data: QueryVote, id: string) => { try { await updateApiEndPointVote(id, data); - const details = await getApiEndPointByFQN(apiEndpointFqn, { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.FOLLOWERS, - TabSpecificField.TAGS, - TabSpecificField.VOTES, - ].join(','), - }); - setApiEndpointDetails(details); + await queryClient.invalidateQueries({ queryKey: apiEndpointCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } }; - const updateApiEndpointDetails = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as APIEndpoint; - - setApiEndpointDetails((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + const updateApiEndpointDetails = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as APIEndpoint; + setApiEndpointDetails((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setApiEndpointDetails] + ); useEffect(() => { fetchResourcePermission(apiEndpointFqn); }, [apiEndpointFqn]); - useEffect(() => { - if (apiEndpointPermissions.ViewAll || apiEndpointPermissions.ViewBasic) { - fetchApiEndPointDetail(apiEndpointFqn); - } - }, [apiEndpointPermissions, apiEndpointFqn]); - - if (isLoading) { + if (permissionsLoading || apiEndpointLoading) { return ; } if (isError) { @@ -286,12 +374,15 @@ const APIEndpointPage = () => { /> ); } + if (!apiEndpointDetails) { + return ; + } return ( fetchApiEndPointDetail(apiEndpointFqn)} + fetchAPIEndpointDetails={refetchApiEndpointDetails} onApiEndpointUpdate={handleApiEndpointUpdate} onFollowApiEndPoint={followApiEndPoint} onToggleDelete={handleToggleDelete} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ChartDetailsPage/ChartDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ChartDetailsPage/ChartDetailsPage.component.tsx index 1b49a7c45fd1..263f1ae9952a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ChartDetailsPage/ChartDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ChartDetailsPage/ChartDetailsPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isUndefined, omitBy, toString } from 'lodash'; @@ -34,11 +35,11 @@ import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { addFollower, - getChartByFqn, patchChartDetails, removeFollower, updateChartVotes, } from '../../rest/chartsAPI'; +import { chartQueryFn, chartQueryKey } from '../../rest/queries/chartQuery'; import { defaultFields } from '../../utils/ChartDetailsUtils'; import { addToRecentViewed, @@ -59,18 +60,116 @@ const ChartDetailsPage = () => { const navigate = useNavigate(); const { getEntityPermissionByFqn } = usePermissionProvider(); const { fqn: chartFQN } = useFqn(); - const [chartDetails, setChartDetails] = useState({} as Chart); - const [isLoading, setLoading] = useState(false); - const [isError, setIsError] = useState(false); + const queryClient = useQueryClient(); + const [permissionsLoading, setPermissionsLoading] = useState(true); const [chartPermissions, setChartPermissions] = useState( DEFAULT_ENTITY_PERMISSION ); - const { id: chartId, version } = chartDetails; + const viewUsagePermission = useMemo( + () => + getPrioritizedViewPermission( + chartPermissions, + PermissionOperation.ViewUsage + ), + [chartPermissions] + ); + + const canViewChart = useMemo( + () => + getPrioritizedViewPermission( + chartPermissions, + PermissionOperation.ViewBasic + ) === true, + [chartPermissions] + ); + + const chartFields = useMemo(() => { + let fields = defaultFields; + if (viewUsagePermission) { + fields += `,${TabSpecificField.USAGE_SUMMARY}`; + } + + return fields; + }, [viewUsagePermission]); + + const chartCacheKey = useMemo( + () => chartQueryKey(chartFQN, chartFields), + [chartFQN, chartFields] + ); + + const { + data: chartDetails, + isLoading: chartLoading, + error: chartError, + } = useQuery({ + queryKey: chartCacheKey, + queryFn: chartQueryFn(chartFQN, chartFields), + enabled: Boolean(chartFQN && canViewChart && !permissionsLoading), + }); + + const isError = useMemo( + () => (chartError as AxiosError | undefined)?.response?.status === 404, + [chartError] + ); + + useEffect(() => { + const status = (chartError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + chartError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.chart'), + entityName: chartFQN, + }) + ); + } + }, [chartError, navigate, chartFQN, t]); + + useEffect(() => { + if (!chartDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(chartDetails), + entityType: EntityType.CHART, + fqn: chartDetails.fullyQualifiedName ?? '', + serviceType: chartDetails.serviceType, + timestamp: 0, + id: chartDetails.id, + }); + }, [chartDetails]); + + const setChartDetails = useCallback( + ( + updater: + | Chart + | undefined + | ((prev: Chart | undefined) => Chart | undefined) + ) => { + queryClient.setQueryData(chartCacheKey, updater); + }, + [queryClient, chartCacheKey] + ); + + const refetchChartDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: chartCacheKey }), + [queryClient, chartCacheKey] + ); + + const { id: chartId, version } = chartDetails ?? {}; + const isFollowing = useMemo( + () => chartDetails?.followers?.some(({ id }) => id === USERId) ?? false, + [chartDetails?.followers, USERId] + ); + const entityName = useMemo(() => getEntityName(chartDetails), [chartDetails]); + // See DashboardDetailsPage for the rationale on NOT using useCallback here. const fetchResourcePermission = async (entityFqn: string) => { - setLoading(true); + setPermissionsLoading(true); try { const entityPermission = await getEntityPermissionByFqn( ResourceEntity.CHART, @@ -84,73 +183,30 @@ const ChartDetailsPage = () => { }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }; - const saveUpdatedChartData = (updatedData: Chart) => { - const jsonPatch = compare(omitBy(chartDetails, isUndefined), updatedData); - - return patchChartDetails(chartId, jsonPatch); - }; - - const viewUsagePermission = useMemo( - () => - getPrioritizedViewPermission( - chartPermissions, - PermissionOperation.ViewUsage - ), - [chartPermissions] - ); - - const fetchChartDetail = async (chartFQN: string) => { - setLoading(true); - - try { - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; + const saveUpdatedChartData = useCallback( + (updatedData: Chart) => { + if (!chartDetails || !chartId) { + return Promise.reject(new Error('Chart not loaded')); } - const res = await getChartByFqn(chartFQN, { fields }); - - const { id, fullyQualifiedName, serviceType } = res; - setChartDetails(res); - - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.CHART, - fqn: fullyQualifiedName ?? '', - serviceType: serviceType, - timestamp: 0, - id: id, - }); + const jsonPatch = compare(omitBy(chartDetails, isUndefined), updatedData); - setLoading(false); - } catch (error) { - if ((error as AxiosError).response?.status === 404) { - setIsError(true); - } else if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } else { - showErrorToast( - error as AxiosError, - t('server.entity-details-fetch-error', { - entityType: t('label.chart'), - entityName: chartFQN, - }) - ); - } - } finally { - setLoading(false); - } - }; + return patchChartDetails(chartId, jsonPatch); + }, + [chartDetails, chartId] + ); const onChartUpdate = async (updatedChart: Chart, key?: keyof Chart) => { try { const response = await saveUpdatedChartData(updatedChart); setChartDetails((previous) => { + if (!previous) { + return previous; + } + return { ...previous, version: response.version, @@ -162,45 +218,76 @@ const ChartDetailsPage = () => { } }; - const followChart = async () => { - try { - const res = await addFollower(chartId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setChartDetails((prev) => ({ - ...prev, - followers: [...(prev?.followers ?? []), ...newValue], - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(chartDetails), - }) + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Chart | undefined } + >({ + mutationFn: async () => { + if (!chartId) { + return; + } + if (isFollowing) { + await removeFollower(chartId, USERId); + } else { + await addFollower(chartId, USERId); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: chartCacheKey }); + const previous = queryClient.getQueryData( + chartCacheKey ); - } - }; + queryClient.setQueryData(chartCacheKey, (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } - const unFollowChart = async () => { - try { - const res = await removeFollower(chartId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as Chart['followers'], + }; + }); - setChartDetails((prev) => ({ - ...prev, - followers: - prev.followers?.filter( - (follower) => follower.id !== oldValue[0].id - ) ?? [], - })); - } catch (error) { + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + chartCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(chartDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { entity: entityName }) + : t('server.entity-follow-error', { entity: entityName }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: chartCacheKey }); + }, + }); + + const followChart = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); + + const unFollowChart = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const versionHandler = () => { version && @@ -224,37 +311,28 @@ const ChartDetailsPage = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updateChartVotes(id, data); - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; - } - const details = await getChartByFqn(chartFQN, { fields }); - setChartDetails(details); + await queryClient.invalidateQueries({ queryKey: chartCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } }; - const updateChartDetailsState = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as Chart; - - setChartDetails((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); - - useEffect(() => { - if (chartPermissions.ViewAll || chartPermissions.ViewBasic) { - fetchChartDetail(chartFQN); - } - }, [chartFQN, chartPermissions]); + const updateChartDetailsState = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as Chart; + setChartDetails((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setChartDetails] + ); useEffect(() => { fetchResourcePermission(chartFQN); }, [chartFQN]); - if (isLoading) { + if (permissionsLoading || chartLoading) { return ; } if (isError) { @@ -275,11 +353,14 @@ const ChartDetailsPage = () => { /> ); } + if (!chartDetails) { + return ; + } return ( fetchChartDetail(chartFQN)} + fetchChart={refetchChartDetails} followChartHandler={followChart} handleToggleDelete={handleToggleDelete} unFollowChartHandler={unFollowChart} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.test.tsx index c9f9604fcdd2..5101ad1769c8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { render, screen, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; @@ -23,6 +23,7 @@ import { getContainerByName, getContainerChildrenByName, } from '../../rest/storageAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import ContainerPage from './ContainerPage'; import { MOCK_CONTAINER_DATA, @@ -181,6 +182,8 @@ jest.mock('../../rest/storageAPI'); jest.mock('../../utils/CommonUtils', () => ({ addToRecentViewed: jest.fn(), + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getEntityMissingError: jest.fn().mockImplementation(() =>
Error
), getFeedCounts: jest.fn().mockReturnValue(0), sortTagsCaseInsensitive: jest.fn().mockImplementation((tags) => tags), @@ -312,7 +315,7 @@ describe('Container Page Component', () => { (getContainerByName as jest.Mock).mockResolvedValue({}); - render( + renderWithQueryClient( @@ -330,7 +333,7 @@ describe('Container Page Component', () => { }); it('fetch container data, if have view permission', async () => { - render( + renderWithQueryClient( @@ -372,7 +375,7 @@ describe('Container Page Component', () => { 'failed to fetch container data' ); // For fetch - render( + renderWithQueryClient( @@ -392,7 +395,7 @@ describe('Container Page Component', () => { it('should render the page container data, with the schema tab selected', async () => { (getContainerByName as jest.Mock).mockResolvedValue(MOCK_CONTAINER_DATA); - render( + renderWithQueryClient( @@ -441,7 +444,7 @@ describe('Container Page Component', () => { it('onClick of follow container should call addContainerFollower', async () => { (getContainerByName as jest.Mock).mockResolvedValue(MOCK_CONTAINER_DATA); - render( + renderWithQueryClient( @@ -461,7 +464,7 @@ describe('Container Page Component', () => { it('tab switch should work', async () => { (getContainerByName as jest.Mock).mockResolvedValue(MOCK_CONTAINER_DATA); - render( + renderWithQueryClient( @@ -490,7 +493,7 @@ describe('Container Page Component', () => { tab: EntityTabs.CHILDREN, }); - render( + renderWithQueryClient( @@ -517,7 +520,7 @@ describe('Container Page Component', () => { it('should pass entity name as pageTitle to PageLayoutV1', async () => { (getContainerByName as jest.Mock).mockResolvedValue(MOCK_CONTAINER_DATA); - render( + renderWithQueryClient( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx index 259bc46052ad..a13b6bd36cc0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Col, Row, Tabs } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; @@ -48,12 +49,16 @@ import { Container } from '../../generated/entity/data/container'; import { Column } from '../../generated/entity/data/table'; import { Operation } from '../../generated/entity/policies/accessControl/resourcePermission'; import { PageType } from '../../generated/system/ui/page'; -import { Include } from '../../generated/type/include'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; +import { + containerQueryFn, + containerQueryKey, + CONTAINER_DEFAULT_FIELDS, +} from '../../rest/queries/containerQuery'; import { addContainerFollower, getContainerByName, @@ -65,6 +70,8 @@ import { } from '../../rest/storageAPI'; import { addToRecentViewed, + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, getEntityMissingError, getFeedCounts, } from '../../utils/CommonUtils'; @@ -99,15 +106,19 @@ const ContainerPage = () => { const { entityFqn: decodedEntityFqn } = useFqn({ type: EntityType.CONTAINER, }); + const queryClient = useQueryClient(); - const [isLoading, setIsLoading] = useState(true); + const [permissionsLoading, setPermissionsLoading] = useState(true); const [hasError, setHasError] = useState(false); + // {@code resolvedEntityFqn} is the FQN we successfully resolved permissions for. When a + // deep link points at a column ({@code container.column}), the initial permission lookup + // 404s and we walk up to the parent container; this stores the parent we ultimately + // landed on so {@code useQuery} keys cleanly against a stable FQN. const [resolvedEntityFqn, setResolvedEntityFqn] = useState(''); const [activeColumnFqn, setActiveColumnFqn] = useState( undefined ); - const [containerData, setContainerData] = useState(); const [containerPermissions, setContainerPermissions] = useState(DEFAULT_ENTITY_PERMISSION); const [isTabExpanded, setIsTabExpanded] = useState(false); @@ -117,6 +128,115 @@ const ContainerPage = () => { ); const [childrenCount, setChildrenCount] = useState(0); + const viewBasicPermission = useMemo( + () => + getPrioritizedViewPermission(containerPermissions, Operation.ViewBasic), + [containerPermissions] + ); + + const containerCacheKey = useMemo( + () => containerQueryKey(resolvedEntityFqn, CONTAINER_DEFAULT_FIELDS), + [resolvedEntityFqn] + ); + + const { + data: containerData, + isLoading: containerLoading, + error: containerError, + } = useQuery({ + queryKey: containerCacheKey, + queryFn: containerQueryFn(resolvedEntityFqn, CONTAINER_DEFAULT_FIELDS), + enabled: Boolean( + resolvedEntityFqn && viewBasicPermission && !permissionsLoading + ), + }); + + const isError = useMemo( + () => (containerError as AxiosError | undefined)?.response?.status === 404, + [containerError] + ); + + useEffect(() => { + if (!containerError) { + return; + } + const status = (containerError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + + return; + } + // Column-deep-link fallback: the URL was a column FQN like + // {@code service.container.column}. Permission resolution succeeded for the column + // FQN (the permission backend returns an empty permission object rather than a 404), + // so {@code resolvedEntityFqn} was committed as the column FQN and the {@link + // useQuery} fired a GET that 404'd because columns aren't containers. Walk up to + // the parent container FQN and re-resolve, marking the original FQN as the active + // column so {@code GenericProvider} can deep-link the side panel. + if ( + status === ClientErrors.NOT_FOUND && + !activeColumnFqn && + resolvedEntityFqn === decodedEntityFqn + ) { + const parentParts = Fqn.split(resolvedEntityFqn).slice(0, -1); + if (parentParts.length > 0) { + setActiveColumnFqn(resolvedEntityFqn); + setResolvedEntityFqn(Fqn.build(...parentParts)); + + return; + } + } + if (status !== ClientErrors.NOT_FOUND) { + showErrorToast(containerError as AxiosError); + } + setHasError(true); + }, [ + containerError, + navigate, + activeColumnFqn, + resolvedEntityFqn, + decodedEntityFqn, + ]); + + useEffect(() => { + if (!containerData) { + return; + } + addToRecentViewed({ + displayName: getEntityName(containerData), + entityType: EntityType.CONTAINER, + fqn: containerData.fullyQualifiedName ?? '', + serviceType: containerData.serviceType, + timestamp: 0, + id: containerData.id, + }); + }, [containerData]); + + const setContainerData = useCallback( + ( + updater: + | Container + | undefined + | ((prev: Container | undefined) => Container | undefined) + ) => { + queryClient.setQueryData( + containerCacheKey, + updater + ); + }, + [queryClient, containerCacheKey] + ); + + const refetchContainerData = useCallback( + () => queryClient.invalidateQueries({ queryKey: containerCacheKey }), + [queryClient, containerCacheKey] + ); + + const fetchContainerDetail = useCallback( + () => refetchContainerData(), + [refetchContainerData] + ); + const handleFeedCount = useCallback( (data: FeedCounts) => setFeedCount(data), [] @@ -125,51 +245,27 @@ const ContainerPage = () => { const getEntityFeedCount = () => getFeedCounts(EntityType.CONTAINER, resolvedEntityFqn, handleFeedCount); - const fetchContainerDetail = async (containerFQN: string) => { - setIsLoading(true); - try { - const response = await getContainerByName(containerFQN, { - fields: [ - TabSpecificField.PARENT, - TabSpecificField.DATAMODEL, - TabSpecificField.OWNERS, - TabSpecificField.TAGS, - TabSpecificField.FOLLOWERS, - TabSpecificField.EXTENSION, - TabSpecificField.DOMAINS, - TabSpecificField.DATA_PRODUCTS, - TabSpecificField.VOTES, - ], - include: Include.All, - }); - addToRecentViewed({ - displayName: getEntityName(response), - entityType: EntityType.CONTAINER, - fqn: response.fullyQualifiedName ?? '', - serviceType: response.serviceType, - timestamp: 0, - id: response.id, - }); - setContainerData(response); - } catch (error) { - if ((error as AxiosError)?.response?.status === ClientErrors.NOT_FOUND) { - throw error; - } - showErrorToast(error as AxiosError); - setHasError(true); - if ((error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } - } finally { - setIsLoading(false); + const fetchTaskCounts = useCallback(() => { + if (resolvedEntityFqn) { + fetchEntityTaskCountsInto(resolvedEntityFqn, setFeedCount); } - }; + }, [resolvedEntityFqn]); + + const fetchActivityCount = useCallback(() => { + if (resolvedEntityFqn) { + fetchEntityActivityCountInto( + EntityType.CONTAINER, + resolvedEntityFqn, + setFeedCount + ); + } + }, [resolvedEntityFqn]); const fetchResourcePermission = async ( containerFQN: string, isFallback = false ) => { - setIsLoading(true); + setPermissionsLoading(true); setHasError(false); try { const entityPermission = await getEntityPermissionByFqn( @@ -178,18 +274,6 @@ const ContainerPage = () => { ); setContainerPermissions(entityPermission); - - const viewBasicPermission = getPrioritizedViewPermission( - entityPermission, - Operation.ViewBasic - ); - - if (viewBasicPermission) { - await fetchContainerDetail(containerFQN); - } else { - setIsLoading(false); - } - setResolvedEntityFqn(containerFQN); // If we successfully resolved using fallback, the remainder is the column @@ -219,7 +303,8 @@ const ContainerPage = () => { }) ); setHasError(true); - setIsLoading(false); + } finally { + setPermissionsLoading(false); } }; @@ -236,7 +321,6 @@ const ContainerPage = () => { const { editCustomAttributePermission, editLineagePermission, - viewBasicPermission, viewAllPermission, viewCustomPropertiesPermission, viewSampleDataPermission, @@ -267,10 +351,6 @@ const ContainerPage = () => { containerPermissions, Operation.EditLineage ) && !deleted, - viewBasicPermission: getPrioritizedViewPermission( - containerPermissions, - Operation.ViewBasic - ), viewAllPermission: containerPermissions.ViewAll, viewCustomPropertiesPermission: getPrioritizedViewPermission( containerPermissions, @@ -342,99 +422,163 @@ const ContainerPage = () => { } }; - const handleFollowContainer = async () => { - const followerId = currentUser?.id ?? ''; - const containerId = containerData?.id ?? ''; - try { + const followContainerMutation = useMutation< + void, + AxiosError, + void, + { previous: Container | undefined } + >({ + mutationFn: async () => { + const containerId = containerData?.id ?? ''; + const followerId = currentUser?.id ?? ''; + if (!containerId) { + return; + } if (isUserFollowing) { - const response = await removeContainerFollower(containerId, followerId); - const { oldValue } = response.changeDescription.fieldsDeleted[0]; - - setContainerData((prev) => ({ - ...(prev as Container), - followers: (containerData?.followers ?? []).filter( - (follower) => follower.id !== oldValue[0].id - ), - })); + await removeContainerFollower(containerId, followerId); } else { - const response = await addContainerFollower(containerId, followerId); - const { newValue } = response.changeDescription.fieldsAdded[0]; + await addContainerFollower(containerId, followerId); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: containerCacheKey }); + const previous = queryClient.getQueryData( + containerCacheKey + ); + queryClient.setQueryData( + containerCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + const userId = currentUser?.id ?? ''; + if (isUserFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== userId), + }; + } + + return { + ...prev, + followers: [ + ...currentFollowers, + { id: userId, type: 'user' }, + ] as Container['followers'], + }; + } + ); - setContainerData((prev) => ({ - ...(prev as Container), - followers: [...(containerData?.followers ?? []), ...newValue], - })); + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + containerCacheKey, + context.previous + ); } - } catch (error) { showErrorToast(error as AxiosError); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: containerCacheKey }); + }, + }); + + const handleFollowContainer = useCallback(async () => { + await followContainerMutation.mutateAsync(); + }, [followContainerMutation]); const handleUpdateOwner = useCallback( async (updatedOwner?: Container['owners']) => { + if (!containerData) { + return; + } try { const { owners: newOwner, version } = await handleUpdateContainerData({ - ...(containerData as Container), + ...containerData, owners: updatedOwner, }); - setContainerData((prev) => ({ - ...(prev as Container), - owners: newOwner, - version, - })); + setContainerData((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + owners: newOwner, + version, + }; + }); } catch (error) { showErrorToast(error as AxiosError); } }, - [containerData, containerData?.owners] + [containerData, handleUpdateContainerData, setContainerData] ); const handleUpdateTier = async (updatedTier?: Tag) => { + if (!containerData) { + return; + } try { - const tierTag = updateTierTag(containerData?.tags ?? [], updatedTier); + const tierTag = updateTierTag(containerData.tags ?? [], updatedTier); const { tags: newTags, version } = await handleUpdateContainerData({ - ...(containerData as Container), + ...containerData, tags: tierTag, }); - setContainerData((prev) => ({ - ...(prev as Container), - tags: newTags, - version, - })); + setContainerData((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + tags: newTags, + version, + }; + }); } catch (error) { showErrorToast(error as AxiosError); } }; - const handleToggleDelete = (version?: number) => { - setContainerData((prev) => { - if (!prev) { - return prev; - } + const handleToggleDelete = useCallback( + (version?: number) => { + setContainerData((prev) => { + if (!prev) { + return prev; + } - return { - ...prev, - deleted: !prev?.deleted, - ...(version ? { version } : {}), - }; - }); - }; + return { + ...prev, + deleted: !prev?.deleted, + ...(version ? { version } : {}), + }; + }); + }, + [setContainerData] + ); const afterDeleteAction = useCallback( (isSoftDelete?: boolean) => !isSoftDelete && navigate('/'), [] ); - const afterDomainUpdateAction = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as Container; + const afterDomainUpdateAction = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as Container; - setContainerData((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + setContainerData((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setContainerData] + ); const handleRestoreContainer = async () => { try { @@ -613,6 +757,16 @@ const ContainerPage = () => { return; } + // Column-deep-link already resolved: the fallback in {@link fetchResourcePermission} + // walked up to a parent that owns this column and set {@code activeColumnFqn} to the + // URL's full FQN. When the React Query container fetch is still in flight, this effect + // re-runs (because {@code containerData} reference changes) — without this guard it + // would re-fire {@code fetchResourcePermission}, which flips {@code permissionsLoading} + // true and cancels the in-flight container fetch, looping until 15s test timeout. + if (resolvedEntityFqn && activeColumnFqn === decodedEntityFqn) { + return; + } + // On mount or when URL FQN changes, start permission fetch fetchResourcePermission(decodedEntityFqn); }, [decodedEntityFqn, resolvedEntityFqn, containerData, activeColumnFqn]); @@ -623,7 +777,8 @@ const ContainerPage = () => { } // Reset so a stale value from the previous container isn't shown. setChildrenCount(0); - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); // Eager-fetch the children total so the tab badge is correct even before // the user opens the Children tab. ContainerChildren is lazily mounted, so @@ -677,11 +832,11 @@ const ContainerPage = () => { [containerData, handleContainerUpdate] ); // Rendering - if (isLoading || loading) { + if (permissionsLoading || containerLoading || loading) { return ; } - if (hasError) { + if (hasError || isError) { return ( {getEntityMissingError(t('label.container'), decodedEntityFqn)} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx index 9e991915ae19..6ea20a097f4f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isUndefined, omitBy, toString } from 'lodash'; @@ -35,11 +36,14 @@ import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { addFollower, - getDashboardByFqn, patchDashboardDetails, removeFollower, updateDashboardVotes, } from '../../rest/dashboardAPI'; +import { + dashboardQueryFn, + dashboardQueryKey, +} from '../../rest/queries/dashboardQuery'; import { addToRecentViewed, getEntityMissingError, @@ -64,21 +68,125 @@ const DashboardDetailsPage = () => { const navigate = useNavigate(); const { getEntityPermissionByFqn } = usePermissionProvider(); const { entityFqn: dashboardFQN } = useFqn({ type: EntityType.DASHBOARD }); + const queryClient = useQueryClient(); - const [dashboardDetails, setDashboardDetails] = useState( - {} as Dashboard - ); - const [isLoading, setLoading] = useState(false); - const [isError, setIsError] = useState(false); - + const [permissionsLoading, setPermissionsLoading] = useState(true); const [dashboardPermissions, setDashboardPermissions] = useState( DEFAULT_ENTITY_PERMISSION ); - const { id: dashboardId, version, charts } = dashboardDetails; + const viewUsagePermission = useMemo( + () => + getPrioritizedViewPermission( + dashboardPermissions, + PermissionOperation.ViewUsage + ), + [dashboardPermissions] + ); + const canViewDashboard = useMemo( + () => + getPrioritizedViewPermission( + dashboardPermissions, + PermissionOperation.ViewBasic + ) === true, + [dashboardPermissions] + ); + + const dashboardFields = useMemo(() => { + let fields = defaultFields; + if (viewUsagePermission) { + fields += `,${TabSpecificField.USAGE_SUMMARY}`; + } + + return fields; + }, [viewUsagePermission]); + + const dashboardCacheKey = useMemo( + () => dashboardQueryKey(dashboardFQN, dashboardFields), + [dashboardFQN, dashboardFields] + ); + + const { + data: dashboardDetails, + isLoading: dashboardLoading, + error: dashboardError, + } = useQuery({ + queryKey: dashboardCacheKey, + queryFn: dashboardQueryFn(dashboardFQN, dashboardFields), + enabled: Boolean(dashboardFQN && canViewDashboard && !permissionsLoading), + }); + + const isError = useMemo( + () => (dashboardError as AxiosError | undefined)?.response?.status === 404, + [dashboardError] + ); + + useEffect(() => { + const status = (dashboardError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + dashboardError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.dashboard'), + entityName: dashboardFQN, + }) + ); + } + }, [dashboardError, navigate, dashboardFQN, t]); + + useEffect(() => { + if (!dashboardDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(dashboardDetails), + entityType: EntityType.DASHBOARD, + fqn: dashboardDetails.fullyQualifiedName ?? '', + serviceType: dashboardDetails.serviceType, + timestamp: 0, + id: dashboardDetails.id, + }); + }, [dashboardDetails]); + + const setDashboardDetails = useCallback( + ( + updater: + | Dashboard + | undefined + | ((prev: Dashboard | undefined) => Dashboard | undefined) + ) => { + queryClient.setQueryData( + dashboardCacheKey, + updater + ); + }, + [queryClient, dashboardCacheKey] + ); + + const refetchDashboardDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: dashboardCacheKey }), + [queryClient, dashboardCacheKey] + ); + + const { id: dashboardId, version, charts } = dashboardDetails ?? {}; + const isFollowing = useMemo( + () => dashboardDetails?.followers?.some(({ id }) => id === USERId) ?? false, + [dashboardDetails?.followers, USERId] + ); + const entityName = useMemo( + () => getEntityName(dashboardDetails), + [dashboardDetails] + ); + + // Intentionally NOT a useCallback. The {@code t} from {@link useTranslation} is a fresh + // reference per render in the testing-library mocked env (and in some non-test paths too), + // which would make this callback unstable and create an infinite re-render via the useEffect + // below. Keep it as a plain function — the useEffect depends only on {@code dashboardFQN}. const fetchResourcePermission = async (entityFqn: string) => { - setLoading(true); + setPermissionsLoading(true); try { const entityPermission = await getEntityPermissionByFqn( ResourceEntity.DASHBOARD, @@ -87,198 +195,182 @@ const DashboardDetailsPage = () => { setDashboardPermissions(entityPermission); } catch { showErrorToast( - t('server.fetch-entity-permissions-error', { - entity: entityFqn, - }) + t('server.fetch-entity-permissions-error', { entity: entityFqn }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }; - const saveUpdatedDashboardData = (updatedData: Dashboard) => { - const jsonPatch = compare( - omitBy(dashboardDetails, isUndefined), - updatedData - ); - - return patchDashboardDetails(dashboardId, jsonPatch); - }; + const saveUpdatedDashboardData = useCallback( + (updatedData: Dashboard) => { + if (!dashboardDetails || !dashboardId) { + return Promise.reject(new Error('Dashboard not loaded')); + } + const jsonPatch = compare( + omitBy(dashboardDetails, isUndefined), + updatedData + ); - const viewUsagePermission = useMemo( - () => - getPrioritizedViewPermission( - dashboardPermissions, - PermissionOperation.ViewUsage - ), - [dashboardPermissions] + return patchDashboardDetails(dashboardId, jsonPatch); + }, + [dashboardDetails, dashboardId] ); - const fetchDashboardDetail = async (dashboardFQN: string) => { - setLoading(true); + const onDashboardUpdate = useCallback( + async (updatedDashboard: Dashboard, key?: keyof Dashboard) => { + try { + const response = await saveUpdatedDashboardData(updatedDashboard); + setDashboardDetails((previous) => { + if (!previous) { + return previous; + } - try { - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; + return { + ...previous, + version: response.version, + ...(key ? { [key]: response[key] } : response), + }; + }); + } catch (error) { + showErrorToast(error as AxiosError); } - const res = await getDashboardByFqn(dashboardFQN, { fields }); - - const { id, fullyQualifiedName, serviceType } = res; - setDashboardDetails(res); - - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.DASHBOARD, - fqn: fullyQualifiedName ?? '', - serviceType: serviceType, - timestamp: 0, - id: id, - }); + }, + [saveUpdatedDashboardData, setDashboardDetails] + ); - setLoading(false); - } catch (error) { - if ((error as AxiosError).response?.status === 404) { - setIsError(true); - } else if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); + // Optimistic follow/unfollow — flip the heart instantly via {@code onMutate}, roll back + // on error, invalidate on settle so background revalidation absorbs any server-side + // adjustments (timestamps etc.). + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Dashboard | undefined } + >({ + mutationFn: async () => { + if (!dashboardId) { + return; + } + if (isFollowing) { + await removeFollower(dashboardId, USERId); } else { - showErrorToast( - error as AxiosError, - t('server.entity-details-fetch-error', { - entityType: t('label.dashboard'), - entityName: dashboardFQN, - }) - ); + await addFollower(dashboardId, USERId); } - } finally { - setLoading(false); - } - }; + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: dashboardCacheKey }); + const previous = queryClient.getQueryData( + dashboardCacheKey + ); + queryClient.setQueryData( + dashboardCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } - const onDashboardUpdate = async ( - updatedDashboard: Dashboard, - key?: keyof Dashboard - ) => { - try { - const response = await saveUpdatedDashboardData(updatedDashboard); - setDashboardDetails((previous) => { - return { - ...previous, - version: response.version, - ...(key ? { [key]: response[key] } : response), - }; - }); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as Dashboard['followers'], + }; + } + ); - const followDashboard = async () => { - try { - const res = await addFollower(dashboardId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setDashboardDetails((prev) => ({ - ...prev, - followers: [...(prev?.followers ?? []), ...newValue], - })); - } catch (error) { + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + dashboardCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(dashboardDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { entity: entityName }) + : t('server.entity-follow-error', { entity: entityName }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: dashboardCacheKey }); + }, + }); - const unFollowDashboard = async () => { - try { - const res = await removeFollower(dashboardId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; + const followDashboard = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); - setDashboardDetails((prev) => ({ - ...prev, - followers: - prev.followers?.filter( - (follower) => follower.id !== oldValue[0].id - ) ?? [], - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(dashboardDetails), - }) - ); - } - }; + const unFollowDashboard = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); - const versionHandler = () => { + const versionHandler = useCallback(() => { version && navigate( getVersionPath(EntityType.DASHBOARD, dashboardFQN, toString(version)) ); - }; + }, [version, dashboardFQN, navigate]); - const handleToggleDelete = (version?: number) => { - setDashboardDetails((prev) => { - if (!prev) { - return prev; - } + const handleToggleDelete = useCallback( + (version?: number) => { + setDashboardDetails((prev) => { + if (!prev) { + return prev; + } - return { - ...prev, - deleted: !prev?.deleted, - ...(version ? { version } : {}), - }; - }); - }; + return { + ...prev, + deleted: !prev?.deleted, + ...(version ? { version } : {}), + }; + }); + }, + [setDashboardDetails] + ); - const updateVote = async (data: QueryVote, id: string) => { - try { - await updateDashboardVotes(id, data); - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; + const updateVote = useCallback( + async (data: QueryVote, id: string) => { + try { + await updateDashboardVotes(id, data); + // Background revalidation pulls authoritative vote counts; the optimistic patch + // (votes increment is already reflected by the UI button state) keeps the page + // responsive in the meantime. + await queryClient.invalidateQueries({ queryKey: dashboardCacheKey }); + } catch (error) { + showErrorToast(error as AxiosError); } - const details = await getDashboardByFqn(dashboardFQN, { fields }); - setDashboardDetails(details); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; + }, + [queryClient, dashboardCacheKey] + ); const updateDashboardDetailsState = useCallback( (data: DataAssetWithDomains) => { const updatedData = data as Dashboard; - - setDashboardDetails((data) => ({ - ...(updatedData ?? data), + setDashboardDetails((prev) => ({ + ...(updatedData ?? prev), version: updatedData.version, })); }, - [] + [setDashboardDetails] ); - useEffect(() => { - if ( - getPrioritizedViewPermission( - dashboardPermissions, - PermissionOperation.ViewBasic - ) - ) { - fetchDashboardDetail(dashboardFQN); - } - }, [dashboardFQN, dashboardPermissions]); - useEffect(() => { fetchResourcePermission(dashboardFQN); }, [dashboardFQN]); - if (isLoading) { + if (permissionsLoading || dashboardLoading) { return ; } if (isError) { @@ -299,12 +391,15 @@ const DashboardDetailsPage = () => { /> ); } + if (!dashboardDetails) { + return ; + } return ( fetchDashboardDetail(dashboardFQN)} + fetchDashboard={refetchDashboardDetails} followDashboardHandler={followDashboard} handleToggleDelete={handleToggleDelete} unFollowDashboardHandler={unFollowDashboard} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx index 2419b911e46e..9921ee947d88 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DashboardDetailsPage/DashboardDetailsPage.test.tsx @@ -10,10 +10,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, render, screen } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import { AxiosError, InternalAxiosRequestConfig } from 'axios'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { getDashboardByFqn } from '../../rest/dashboardAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import DashboardDetailsPage from './DashboardDetailsPage.component'; // Mock the required dependencies @@ -65,7 +66,7 @@ describe('DashboardDetailsPage', () => { Promise.resolve(mockDashboard) ); - render(); + renderWithQueryClient(); expect(screen.getByTestId('loader')).toBeInTheDocument(); }); @@ -74,10 +75,14 @@ describe('DashboardDetailsPage', () => { (getDashboardByFqn as jest.Mock).mockResolvedValue(mockDashboard); await act(async () => { - render(); + renderWithQueryClient(); }); - expect(screen.getByText('Dashboard Details Component')).toBeInTheDocument(); + await waitFor(() => + expect( + screen.getByText('Dashboard Details Component') + ).toBeInTheDocument() + ); }); it('should show error placeholder when dashboard is not found', async () => { @@ -100,15 +105,19 @@ describe('DashboardDetailsPage', () => { }); await act(async () => { - render(); + renderWithQueryClient(); }); - expect(getDashboardByFqn).toHaveBeenCalledWith('test-dashboard', { - fields: - 'domains,owners, followers, tags, charts,votes,dataProducts,extension,usageSummary', - }); + await waitFor(() => + expect(getDashboardByFqn).toHaveBeenCalledWith('test-dashboard', { + fields: + 'domains,owners, followers, tags, charts,votes,dataProducts,extension,usageSummary', + }) + ); - expect(screen.getByTestId('no-data-placeholder')).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId('no-data-placeholder')).toBeInTheDocument() + ); }); it('should show permission error when user lacks view permissions', async () => { @@ -120,11 +129,13 @@ describe('DashboardDetailsPage', () => { }); await act(async () => { - render(); + renderWithQueryClient(); }); - expect( - screen.getByTestId('permission-error-placeholder') - ).toBeInTheDocument(); + await waitFor(() => + expect( + screen.getByTestId('permission-error-placeholder') + ).toBeInTheDocument() + ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx index 9c3fe0e4a9c5..b0bcc1ea7b2a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isUndefined, omitBy } from 'lodash'; @@ -33,43 +34,51 @@ import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; import { EntityType, TabSpecificField } from '../../enums/entity.enum'; import { Tag } from '../../generated/entity/classification/tag'; import { DashboardDataModel } from '../../generated/entity/data/dashboardDataModel'; -import { Include } from '../../generated/type/include'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { addDataModelFollower, - getDataModelByFqn, patchDataModelDetails, removeDataModelFollower, updateDataModelVotes, } from '../../rest/dataModelsAPI'; +import { + dashboardDataModelQueryFn, + dashboardDataModelQueryKey, +} from '../../rest/queries/dashboardDataModelQuery'; import { addToRecentViewed, getEntityMissingError, } from '../../utils/CommonUtils'; import { getEntityName } from '../../utils/EntityUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; -import { getTierTags } from '../../utils/TableUtils'; import { updateTierTag } from '../../utils/TagsUtils'; import { showErrorToast } from '../../utils/ToastUtils'; +const DATA_MODEL_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.TAGS, + TabSpecificField.FOLLOWERS, + TabSpecificField.VOTES, + TabSpecificField.DOMAINS, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.EXTENSION, +].join(','); + const DataModelsPage = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { currentUser } = useApplicationStore(); const { getEntityPermissionByFqn } = usePermissionProvider(); + const queryClient = useQueryClient(); const { entityFqn: dashboardDataModelFQN } = useFqn({ type: EntityType.DASHBOARD_DATA_MODEL, }); - const [isLoading, setIsLoading] = useState(false); - const [hasError, setHasError] = useState(false); + const [permissionsLoading, setPermissionsLoading] = useState(true); const [dataModelPermissions, setDataModelPermissions] = useState(DEFAULT_ENTITY_PERMISSION); - const [dataModelData, setDataModelData] = useState( - {} as DashboardDataModel - ); const { hasViewPermission } = useMemo(() => { return { @@ -78,21 +87,91 @@ const DataModelsPage = () => { }; }, [dataModelPermissions]); - const { isUserFollowing } = useMemo(() => { - return { - tier: getTierTags(dataModelData?.tags ?? []), - isUserFollowing: dataModelData?.followers?.some( + const dataModelCacheKey = useMemo( + () => dashboardDataModelQueryKey(dashboardDataModelFQN, DATA_MODEL_FIELDS), + [dashboardDataModelFQN] + ); + + const { + data: dataModelData, + isLoading: dataModelLoading, + error: dataModelError, + } = useQuery({ + queryKey: dataModelCacheKey, + queryFn: dashboardDataModelQueryFn( + dashboardDataModelFQN, + DATA_MODEL_FIELDS + ), + enabled: Boolean( + dashboardDataModelFQN && hasViewPermission && !permissionsLoading + ), + }); + + const isError = useMemo( + () => (dataModelError as AxiosError | undefined)?.response?.status === 404, + [dataModelError] + ); + + useEffect(() => { + const status = (dataModelError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (dataModelError && status !== 404) { + showErrorToast(dataModelError as AxiosError); + } + }, [dataModelError, navigate]); + + useEffect(() => { + if (!dataModelData) { + return; + } + addToRecentViewed({ + displayName: getEntityName(dataModelData), + entityType: EntityType.DASHBOARD_DATA_MODEL, + fqn: dataModelData.fullyQualifiedName ?? '', + serviceType: dataModelData.serviceType, + timestamp: 0, + id: dataModelData.id, + }); + }, [dataModelData]); + + const setDataModelData = useCallback( + ( + updater: + | DashboardDataModel + | undefined + | (( + prev: DashboardDataModel | undefined + ) => DashboardDataModel | undefined) + ) => { + queryClient.setQueryData( + dataModelCacheKey, + updater + ); + }, + [queryClient, dataModelCacheKey] + ); + + const refetchDataModelDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: dataModelCacheKey }), + [queryClient, dataModelCacheKey] + ); + + const isUserFollowing = useMemo( + () => + dataModelData?.followers?.some( ({ id }: { id: string }) => id === currentUser?.id - ), - }; - }, [dataModelData, currentUser]); + ) ?? false, + [dataModelData?.followers, currentUser?.id] + ); - const fetchResourcePermission = async (dashboardDataModelFQN: string) => { - setIsLoading(true); + // See DashboardDetailsPage for the rationale on NOT using useCallback here. + const fetchResourcePermission = async (entityFqn: string) => { + setPermissionsLoading(true); try { const entityPermission = await getEntityPermissionByFqn( ResourceEntity.DASHBOARD_DATA_MODEL, - dashboardDataModelFQN + entityFqn ); setDataModelPermissions(entityPermission); } catch { @@ -102,72 +181,90 @@ const DataModelsPage = () => { }) ); } finally { - setIsLoading(false); - } - }; - - const fetchDataModelDetails = async (dashboardDataModelFQN: string) => { - setIsLoading(true); - try { - const response = await getDataModelByFqn(dashboardDataModelFQN, { - // eslint-disable-next-line max-len - fields: `${TabSpecificField.OWNERS},${TabSpecificField.TAGS},${TabSpecificField.FOLLOWERS},${TabSpecificField.VOTES},${TabSpecificField.DOMAINS},${TabSpecificField.DATA_PRODUCTS},${TabSpecificField.EXTENSION}`, - include: Include.All, - }); - setDataModelData(response); - - addToRecentViewed({ - displayName: getEntityName(response), - entityType: EntityType.DASHBOARD_DATA_MODEL, - fqn: response.fullyQualifiedName ?? '', - serviceType: response.serviceType, - timestamp: 0, - id: response.id, - }); - } catch (error) { - showErrorToast(error as AxiosError); - setHasError(true); - if ((error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN) { - navigate(ROUTES.FORBIDDEN); - } - } finally { - setIsLoading(false); + setPermissionsLoading(false); } }; - const handleUpdateDataModelData = (updatedData: DashboardDataModel) => { - const jsonPatch = compare(omitBy(dataModelData, isUndefined), updatedData); + const handleUpdateDataModelData = useCallback( + (updatedData: DashboardDataModel) => { + const jsonPatch = compare( + omitBy(dataModelData ?? {}, isUndefined), + updatedData + ); - return patchDataModelDetails(dataModelData?.id ?? '', jsonPatch); - }; + return patchDataModelDetails(dataModelData?.id ?? '', jsonPatch); + }, + [dataModelData] + ); - const handleFollowDataModel = async () => { - const followerId = currentUser?.id ?? ''; - const dataModelId = dataModelData?.id ?? ''; - try { + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: DashboardDataModel | undefined } + >({ + mutationFn: async () => { + const dataModelId = dataModelData?.id ?? ''; + const followerId = currentUser?.id ?? ''; if (isUserFollowing) { - const response = await removeDataModelFollower(dataModelId, followerId); - const { oldValue } = response.changeDescription.fieldsDeleted[0]; - - setDataModelData((prev) => ({ - ...(prev as DashboardDataModel), - followers: (dataModelData?.followers || []).filter( - (follower) => follower.id !== oldValue[0].id - ), - })); + await removeDataModelFollower(dataModelId, followerId); } else { - const response = await addDataModelFollower(dataModelId, followerId); - const { newValue } = response.changeDescription.fieldsAdded[0]; + await addDataModelFollower(dataModelId, followerId); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: dataModelCacheKey }); + const previous = queryClient.getQueryData( + dataModelCacheKey + ); + const followerId = currentUser?.id ?? ''; + queryClient.setQueryData( + dataModelCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isUserFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== followerId), + }; + } + + return { + ...prev, + followers: [ + ...currentFollowers, + { id: followerId, type: 'user' }, + ] as DashboardDataModel['followers'], + }; + } + ); - setDataModelData((prev) => ({ - ...(prev as DashboardDataModel), - followers: [...(dataModelData?.followers ?? []), ...newValue], - })); + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + dataModelCacheKey, + context.previous + ); } - } catch (error) { showErrorToast(error as AxiosError); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: dataModelCacheKey }); + }, + }); + + const handleFollowDataModel = useCallback(async () => { + try { + await followMutation.mutateAsync(); + } catch { + // Errors surfaced via mutation onError handler — swallow rethrow. } - }; + }, [followMutation]); const handleUpdateOwner = useCallback( async (updatedOwners?: DashboardDataModel['owners']) => { @@ -177,118 +274,125 @@ const DataModelsPage = () => { owners: updatedOwners, }); - setDataModelData((prev) => ({ - ...(prev as DashboardDataModel), - owners: newOwners, - version, - })); + setDataModelData((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + owners: newOwners, + version, + }; + }); } catch (error) { showErrorToast(error as AxiosError); } }, - [dataModelData, dataModelData?.owners] + [dataModelData, handleUpdateDataModelData, setDataModelData] ); - const handleUpdateTier = async (updatedTier?: Tag) => { - try { - const tags = updateTierTag(dataModelData?.tags ?? [], updatedTier); - const { tags: newTags, version } = await handleUpdateDataModelData({ - ...(dataModelData as DashboardDataModel), - tags, - }); + const handleUpdateTier = useCallback( + async (updatedTier?: Tag) => { + try { + const tags = updateTierTag(dataModelData?.tags ?? [], updatedTier); + const { tags: newTags, version } = await handleUpdateDataModelData({ + ...(dataModelData as DashboardDataModel), + tags, + }); - setDataModelData((prev) => ({ - ...(prev as DashboardDataModel), - tags: newTags, - version, - })); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; + setDataModelData((prev) => { + if (!prev) { + return prev; + } - const handleUpdateDataModel = async ( - updatedDataModel: DashboardDataModel, - key?: keyof DashboardDataModel - ) => { - try { - const response = await handleUpdateDataModelData(updatedDataModel); + return { + ...prev, + tags: newTags, + version, + }; + }); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, + [dataModelData, handleUpdateDataModelData, setDataModelData] + ); - setDataModelData(() => ({ - ...response, - ...(key && { [key]: response[key] }), - })); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; + const handleUpdateDataModel = useCallback( + async ( + updatedDataModel: DashboardDataModel, + key?: keyof DashboardDataModel + ) => { + try { + const response = await handleUpdateDataModelData(updatedDataModel); - const handleToggleDelete = (version?: number) => { - setDataModelData((prev) => { - if (!prev) { - return prev; - } + setDataModelData((prev) => { + if (!prev) { + return response; + } - return { - ...prev, - deleted: !prev?.deleted, - ...(version ? { version } : {}), - }; - }); - }; + return { + ...response, + ...(key && { [key]: response[key] }), + }; + }); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, + [handleUpdateDataModelData, setDataModelData] + ); - const updateVote = async (data: QueryVote, id: string) => { - try { - await updateDataModelVotes(id, data); - const details = await getDataModelByFqn( - dashboardDataModelFQN, - - { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.TAGS, - TabSpecificField.FOLLOWERS, - TabSpecificField.VOTES, - TabSpecificField.DOMAINS, - TabSpecificField.DATA_PRODUCTS, - ], - include: Include.All, + const handleToggleDelete = useCallback( + (version?: number) => { + setDataModelData((prev) => { + if (!prev) { + return prev; } - ); - setDataModelData(details); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; + + return { + ...prev, + deleted: !prev?.deleted, + ...(version ? { version } : {}), + }; + }); + }, + [setDataModelData] + ); + + const updateVote = useCallback( + async (data: QueryVote, id: string) => { + try { + await updateDataModelVotes(id, data); + await queryClient.invalidateQueries({ queryKey: dataModelCacheKey }); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, + [queryClient, dataModelCacheKey] + ); const updateDataModelDetailsState = useCallback( (data: DataAssetWithDomains) => { const updatedData = data as DashboardDataModel; - - setDataModelData((data) => ({ - ...(updatedData ?? data), + setDataModelData((prev) => ({ + ...(updatedData ?? prev), version: updatedData.version, })); }, - [] + [setDataModelData] ); - useEffect(() => { - if (hasViewPermission) { - fetchDataModelDetails(dashboardDataModelFQN); - } - }, [dashboardDataModelFQN, dataModelPermissions]); - useEffect(() => { fetchResourcePermission(dashboardDataModelFQN); }, [dashboardDataModelFQN]); - // Rendering - if (isLoading) { + if (permissionsLoading || dataModelLoading) { return ; } - if (hasError) { + if (isError) { return ( {getEntityMissingError(t('label.data-model'), dashboardDataModelFQN)} @@ -296,7 +400,7 @@ const DataModelsPage = () => { ); } - if (!hasViewPermission && !isLoading) { + if (!dataModelPermissions.ViewAll && !dataModelPermissions.ViewBasic) { return ( { ); } + if (!dataModelData) { + return ; + } + return ( fetchDataModelDetails(dashboardDataModelFQN)} + fetchDataModel={refetchDataModelDetails} handleFollowDataModel={handleFollowDataModel} handleToggleDelete={handleToggleDelete} handleUpdateOwner={handleUpdateOwner} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.test.tsx index 07dda9ff86e9..76cc78fb5385 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.test.tsx @@ -10,8 +10,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { mockUserData } from '../../components/Settings/Users/mocks/User.mocks'; import { ResourceEntity } from '../../context/PermissionProvider/PermissionProvider.interface'; @@ -150,44 +158,72 @@ jest.mock('../../utils/EntityUtils', () => ({ getEntityName: jest.fn().mockImplementation(() => 'testEntityName'), })); +const renderPage = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + mutations: { retry: false }, + }, + }); + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return render(, { wrapper: Wrapper }); +}; + describe('DataModelPage component', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getDataModelByFqn as jest.Mock).mockResolvedValue({}); + (patchDataModelDetails as jest.Mock).mockResolvedValue({}); + (addDataModelFollower as jest.Mock).mockResolvedValue({}); + (removeDataModelFollower as jest.Mock).mockResolvedValue({}); + (updateDataModelVotes as jest.Mock).mockResolvedValue({}); + mockGetEntityPermissionByFqn.mockResolvedValue({ + ViewAll: true, + ViewBasic: true, + }); + }); + it('should render necessary elements', async () => { await act(async () => { - render(, { wrapper: MemoryRouter }); + renderPage(); }); - expect(getDataModelByFqn).toHaveBeenCalled(); - expect(screen.getByText('DataModelDetails')).toBeInTheDocument(); + await waitFor(() => { + expect(getDataModelByFqn).toHaveBeenCalled(); + }); + + expect(await screen.findByText('DataModelDetails')).toBeInTheDocument(); }); it('toggle delete action check', async () => { await act(async () => { - render(, { wrapper: MemoryRouter }); + renderPage(); }); - // toggle delete fireEvent.click( - screen.getByRole('button', { + await screen.findByRole('button', { name: TOGGLE_DELETE, }) ); - expect(screen.getByText(DATA_MODEL_DELETED)).toBeInTheDocument(); + expect(await screen.findByText(DATA_MODEL_DELETED)).toBeInTheDocument(); }); it('follow data model action check', async () => { - await act(async () => { - render(, { wrapper: MemoryRouter }); - }); - - // follow data model - fireEvent.click( - screen.getByRole('button', { - name: FOLLOW_DATA_MODEL, - }) + renderPage(); + await waitFor(() => + expect(screen.getByText('DataModelDetails')).toBeInTheDocument() ); - expect(addDataModelFollower).toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: FOLLOW_DATA_MODEL })); + + await waitFor(() => expect(addDataModelFollower).toHaveBeenCalled()); }); it('unfollow data model action check', async () => { @@ -199,112 +235,80 @@ describe('DataModelPage component', () => { ], }); - await act(async () => { - render(, { wrapper: MemoryRouter }); - }); - - // unfollow data model - fireEvent.click( - screen.getByRole('button', { - name: FOLLOW_DATA_MODEL, - }) + renderPage(); + await waitFor(() => + expect(screen.getByText('DataModelDetails')).toBeInTheDocument() ); - expect(removeDataModelFollower).toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: FOLLOW_DATA_MODEL })); + + await waitFor(() => expect(removeDataModelFollower).toHaveBeenCalled()); }); it('update data model action check', async () => { - await act(async () => { - render(, { wrapper: MemoryRouter }); - }); - - // update data model - fireEvent.click( - screen.getByRole('button', { - name: UPDATE_DATA_MODEL, - }) + renderPage(); + await waitFor(() => + expect(screen.getByText('DataModelDetails')).toBeInTheDocument() ); - expect(patchDataModelDetails).toHaveBeenCalledTimes(3); + fireEvent.click(screen.getByRole('button', { name: UPDATE_DATA_MODEL })); + + await waitFor(() => expect(patchDataModelDetails).toHaveBeenCalledTimes(3)); }); it('update vote action check', async () => { - await act(async () => { - render(, { wrapper: MemoryRouter }); - }); - - // update vote - fireEvent.click( - screen.getByRole('button', { - name: UPDATE_VOTE, - }) + renderPage(); + await waitFor(() => + expect(screen.getByText('DataModelDetails')).toBeInTheDocument() ); - expect(updateDataModelVotes).toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: UPDATE_VOTE })); + + await waitFor(() => expect(updateDataModelVotes).toHaveBeenCalled()); }); it('errors check', async () => { (patchDataModelDetails as jest.Mock).mockRejectedValue(ERROR); - (addDataModelFollower as jest.Mock).mockRejectedValueOnce(ERROR); - (updateDataModelVotes as jest.Mock).mockRejectedValueOnce(ERROR); - - await act(async () => { - render(, { wrapper: MemoryRouter }); - }); + (addDataModelFollower as jest.Mock).mockRejectedValue(ERROR); + (updateDataModelVotes as jest.Mock).mockRejectedValue(ERROR); - // create thread - userEvent.click( - screen.getByRole('button', { - name: CREATE_THREAD, - }) + renderPage(); + await waitFor(() => + expect(screen.getByText('DataModelDetails')).toBeInTheDocument() ); - await act(async () => { - // update data model - fireEvent.click( - screen.getByRole('button', { - name: UPDATE_DATA_MODEL, - }) - ); - - // follow data model - fireEvent.click( - screen.getByRole('button', { - name: FOLLOW_DATA_MODEL, - }) - ); - - // update vote - fireEvent.click( - screen.getByRole('button', { - name: UPDATE_VOTE, - }) - ); - }); + userEvent.click(screen.getByRole('button', { name: CREATE_THREAD })); - expect(showErrorToast).toHaveBeenCalledTimes(5); + fireEvent.click(screen.getByRole('button', { name: UPDATE_DATA_MODEL })); - (patchDataModelDetails as jest.Mock).mockResolvedValue({}); + fireEvent.click(screen.getByRole('button', { name: FOLLOW_DATA_MODEL })); + + fireEvent.click(screen.getByRole('button', { name: UPDATE_VOTE })); + + await waitFor(() => expect(showErrorToast).toHaveBeenCalled()); }); it('error when rendering component', async () => { mockGetEntityPermissionByFqn.mockRejectedValueOnce(ERROR); await act(async () => { - render(, { wrapper: MemoryRouter }); + renderPage(); }); - expect(screen.getByText(ERROR_PLACEHOLDER)).toBeInTheDocument(); + expect(await screen.findByText(ERROR_PLACEHOLDER)).toBeInTheDocument(); expect(showErrorToast).toHaveBeenCalledWith(FETCH_ENTITY_PERMISSION_ERROR); }); it('error while fetching data model data', async () => { (getDataModelByFqn as jest.Mock).mockImplementationOnce(() => { - return Promise.reject(new Error(ERROR)); + return Promise.reject({ + response: { status: 404 }, + message: ERROR, + }); }); await act(async () => { - render(, { wrapper: MemoryRouter }); + renderPage(); }); expect(useFqn).toHaveBeenCalled(); @@ -312,11 +316,6 @@ describe('DataModelPage component', () => { ResourceEntity.DASHBOARD_DATA_MODEL, 'testFqn' ); - expect(screen.getByText(ERROR_PLACEHOLDER)).toBeInTheDocument(); - expect(showErrorToast).toHaveBeenCalledWith( - expect.objectContaining({ - message: ERROR, - }) - ); + expect(await screen.findByText(ERROR_PLACEHOLDER)).toBeInTheDocument(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.test.tsx index a4fcb512ee64..f4d8ac8416fa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.test.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { findByTestId, findByText, render } from '@testing-library/react'; +import { findByTestId, findByText } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; @@ -19,6 +19,7 @@ import { getDatabaseDetailsByFQN, patchDatabaseDetails, } from '../../rest/databaseAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import DatabaseDetailsPage from './DatabaseDetailsPage'; const mockDatabase = { @@ -301,9 +302,11 @@ jest.mock('../../hooks/useEntityRules', () => ({ describe('Test DatabaseDetails page', () => { it('Component should render', async () => { - const { container } = render(, { - wrapper: MemoryRouter, - }); + const { container } = renderWithQueryClient( + + + + ); const entityHeader = await findByText(container, 'DataAssetsHeader'); const descriptionContainer = await findByText(container, 'Description'); @@ -331,9 +334,11 @@ describe('Test DatabaseDetails page', () => { }, }) ); - const { container } = render(, { - wrapper: MemoryRouter, - }); + const { container } = renderWithQueryClient( + + + + ); const errorPlaceholder = await findByTestId( container, @@ -353,9 +358,11 @@ describe('Test DatabaseDetails page', () => { }, }) ); - const { container } = render(, { - wrapper: MemoryRouter, - }); + const { container } = renderWithQueryClient( + + + + ); const entityHeader = await findByText(container, 'DataAssetsHeader'); const descriptionContainer = await findByText(container, 'Description'); @@ -370,9 +377,11 @@ describe('Test DatabaseDetails page', () => { }); it('should pass entity name as pageTitle to PageLayoutV1', async () => { - render(, { - wrapper: MemoryRouter, - }); + renderWithQueryClient( + + + + ); await findByText(document.body, 'DataAssetsHeader'); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx index 0224f07c5d64..9f0220f788a8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Col, Row, Tabs } from 'antd'; import { AxiosError } from 'axios'; import { compare, Operation } from 'fast-json-patch'; @@ -70,7 +71,17 @@ import { restoreDatabase, updateDatabaseVotes, } from '../../rest/databaseAPI'; -import { getEntityMissingError, getFeedCounts } from '../../utils/CommonUtils'; +import { + databaseQueryFn, + databaseQueryKey, + DATABASE_DEFAULT_FIELDS, +} from '../../rest/queries/databaseQuery'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getEntityMissingError, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -106,14 +117,11 @@ const DatabaseDetails: FunctionComponent = () => { const { entityFqn: decodedDatabaseFQN } = useFqn({ type: EntityType.DATABASE, }); - const [isLoading, setIsLoading] = useState(true); + const queryClient = useQueryClient(); + const [permissionsLoading, setPermissionsLoading] = useState(true); const { customizedPage, isLoading: loading } = useCustomPages( PageType.Database ); - const [database, setDatabase] = useState({} as Database); - const [serviceType, setServiceType] = useState(); - const [isDatabaseDetailsLoading, setIsDatabaseDetailsLoading] = - useState(true); const [schemaInstanceCount, setSchemaInstanceCount] = useState(0); const [feedCount, setFeedCount] = useState( FEED_COUNT_INITIAL_DATA @@ -125,32 +133,94 @@ const DatabaseDetails: FunctionComponent = () => { const isMounting = useRef(true); const [isTabExpanded, setIsTabExpanded] = useState(false); - const { - version: currentVersion, - deleted, - id: databaseId, - } = useMemo(() => database, [database]); - const { currentUser } = useApplicationStore(); const USERId = currentUser?.id ?? ''; - const tier = getTierTags(database?.tags ?? []); const [databasePermission, setDatabasePermission] = useState(DEFAULT_ENTITY_PERMISSION); + const hasViewBasicPermission = useMemo( + () => + getPrioritizedViewPermission( + databasePermission, + PermissionOperation.ViewBasic + ) === true, + [databasePermission] + ); + + const databaseCacheKey = useMemo( + () => databaseQueryKey(decodedDatabaseFQN, DATABASE_DEFAULT_FIELDS), + [decodedDatabaseFQN] + ); + + const { + data: database, + isLoading: databaseLoading, + error: databaseError, + } = useQuery({ + queryKey: databaseCacheKey, + queryFn: databaseQueryFn(decodedDatabaseFQN, DATABASE_DEFAULT_FIELDS), + enabled: Boolean( + decodedDatabaseFQN && hasViewBasicPermission && !permissionsLoading + ), + }); + + const isError = useMemo(() => Boolean(databaseError), [databaseError]); + + useEffect(() => { + const status = (databaseError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + databaseError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.database'), + entityName: decodedDatabaseFQN, + }) + ); + } + }, [databaseError, navigate, decodedDatabaseFQN, t]); + + const setDatabase = useCallback( + ( + updater: + | Database + | undefined + | ((prev: Database | undefined) => Database | undefined) + ) => { + queryClient.setQueryData(databaseCacheKey, updater); + }, + [queryClient, databaseCacheKey] + ); + + const refetchDatabase = useCallback( + () => queryClient.invalidateQueries({ queryKey: databaseCacheKey }), + [queryClient, databaseCacheKey] + ); + + const { + version: currentVersion, + deleted, + id: databaseId, + serviceType, + } = useMemo(() => database ?? ({} as Database), [database]); + const tier = getTierTags(database?.tags ?? []); + const extraDropdownContent = useMemo( () => entityUtilClassBase.getManageExtraOptions( EntityType.DATABASE, decodedDatabaseFQN, databasePermission, - database, + database ?? ({} as Database), navigate ), [decodedDatabaseFQN, databasePermission, database] ); - const fetchDatabasePermission = async () => { - setIsLoading(true); + + const fetchDatabasePermission = useCallback(async () => { + setPermissionsLoading(true); try { const response = await getEntityPermissionByFqn( ResourceEntity.DATABASE, @@ -160,17 +230,33 @@ const DatabaseDetails: FunctionComponent = () => { } catch { // Error } finally { - setIsLoading(false); + setPermissionsLoading(false); } - }; + }, [decodedDatabaseFQN, getEntityPermissionByFqn]); const handleFeedCount = useCallback((data: FeedCounts) => { setFeedCount(data); }, []); - const getEntityFeedCount = () => { + const getEntityFeedCount = useCallback(() => { getFeedCounts(EntityType.DATABASE, decodedDatabaseFQN, handleFeedCount); - }; + }, [decodedDatabaseFQN, handleFeedCount]); + + const fetchTaskCounts = useCallback(() => { + if (decodedDatabaseFQN) { + fetchEntityTaskCountsInto(decodedDatabaseFQN, setFeedCount); + } + }, [decodedDatabaseFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDatabaseFQN) { + fetchEntityActivityCountInto( + EntityType.DATABASE, + decodedDatabaseFQN, + setFeedCount + ); + } + }, [decodedDatabaseFQN]); const fetchDatabaseSchemaCount = useCallback(async () => { if (isEmpty(decodedDatabaseFQN)) { @@ -178,7 +264,6 @@ const DatabaseDetails: FunctionComponent = () => { } try { - setIsLoading(true); const { paging } = await getDatabaseSchemas({ databaseName: decodedDatabaseFQN, limit: 0, @@ -187,54 +272,28 @@ const DatabaseDetails: FunctionComponent = () => { setSchemaInstanceCount(paging.total); } catch (error) { showErrorToast(error as AxiosError); - } finally { - setIsLoading(false); } }, [decodedDatabaseFQN]); - const getDetailsByFQN = () => { - setIsDatabaseDetailsLoading(true); - getDatabaseDetailsByFQN(decodedDatabaseFQN, { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.TAGS, - TabSpecificField.DOMAINS, - TabSpecificField.VOTES, - TabSpecificField.EXTENSION, - TabSpecificField.DATA_PRODUCTS, - TabSpecificField.FOLLOWERS, - ].join(','), - include: Include.All, - }) - .then((res) => { - if (res) { - const { serviceType } = res; - setDatabase(res); - setServiceType(serviceType); - } - }) - .catch((error) => { - // Error - if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } - }) - .finally(() => { - setIsLoading(false); - setIsDatabaseDetailsLoading(false); - }); - }; + const getDetailsByFQN = useCallback( + () => refetchDatabase(), + [refetchDatabase] + ); - const saveUpdatedDatabaseData = (updatedData: Database) => { - let jsonPatch: Operation[] = []; - if (database) { - jsonPatch = compare(database, updatedData); - } + const saveUpdatedDatabaseData = useCallback( + (updatedData: Database) => { + if (!database) { + return Promise.reject(new Error('Database not loaded')); + } + let jsonPatch: Operation[] = []; + if (database) { + jsonPatch = compare(database, updatedData); + } - return patchDatabaseDetails(database.id ?? '', jsonPatch); - }; + return patchDatabaseDetails(database.id ?? '', jsonPatch); + }, + [database] + ); const activeTabHandler = (key: string) => { if (key !== activeTab) { @@ -251,23 +310,29 @@ const DatabaseDetails: FunctionComponent = () => { } }; - const settingsUpdateHandler = async (data: Database) => { - try { - const res = await saveUpdatedDatabaseData(data); - - setDatabase(res); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-updating-error', { - entity: t('label.database'), - }) - ); - } - }; + const settingsUpdateHandler = useCallback( + async (data: Database) => { + try { + const res = await saveUpdatedDatabaseData(data); + + setDatabase(res); + } catch (error) { + showErrorToast( + error as AxiosError, + t('server.entity-updating-error', { + entity: t('label.database'), + }) + ); + } + }, + [saveUpdatedDatabaseData, setDatabase, t] + ); const handleUpdateOwner = useCallback( async (owners: Database['owners']) => { + if (!database) { + return; + } const updatedData = { ...database, owners, @@ -275,15 +340,16 @@ const DatabaseDetails: FunctionComponent = () => { await settingsUpdateHandler(updatedData as Database); }, - [database, database?.owners, settingsUpdateHandler] + [database, settingsUpdateHandler] ); useEffect(() => { - getEntityFeedCount(); - }, []); + fetchTaskCounts(); + fetchActivityCount(); + }, [decodedDatabaseFQN]); useEffect(() => { - if (withinPageSearch && serviceType) { + if (withinPageSearch && serviceType && database) { navigate( getExplorePath({ search: withinPageSearch, @@ -295,21 +361,13 @@ const DatabaseDetails: FunctionComponent = () => { { replace: true } ); } - }, [withinPageSearch]); + }, [withinPageSearch, serviceType, database]); useEffect(() => { - if ( - getPrioritizedViewPermission( - databasePermission, - PermissionOperation.ViewBasic - ) - ) { - getDetailsByFQN(); + if (hasViewBasicPermission && decodedDatabaseFQN) { fetchDatabaseSchemaCount(); - } else { - setIsDatabaseDetailsLoading(false); } - }, [databasePermission, decodedDatabaseFQN]); + }, [hasViewBasicPermission, decodedDatabaseFQN, fetchDatabaseSchemaCount]); useEffect(() => { fetchDatabasePermission(); @@ -322,6 +380,9 @@ const DatabaseDetails: FunctionComponent = () => { const handleUpdateTier = useCallback( (newTier?: Tag) => { + if (!database) { + return Promise.resolve(); + } const tierTag = updateTierTag(database?.tags ?? [], newTier); const updatedTableDetails = { ...database, @@ -370,6 +431,9 @@ const DatabaseDetails: FunctionComponent = () => { [database, currentUser] ); const handleRestoreDatabase = useCallback(async () => { + if (!database) { + return; + } try { const { version: newVersion } = await restoreDatabase(database.id ?? ''); showSuccessToast( @@ -386,7 +450,7 @@ const DatabaseDetails: FunctionComponent = () => { }) ); } - }, [database.id]); + }, [database?.id]); const versionHandler = useCallback(() => { currentVersion && @@ -404,23 +468,18 @@ const DatabaseDetails: FunctionComponent = () => { editCustomAttributePermission, viewAllPermission, viewCustomPropertiesPermission, - hasViewBasicPermission, } = useMemo( () => ({ editCustomAttributePermission: getPrioritizedEditPermission( databasePermission, PermissionOperation.EditCustomFields - ) && !database.deleted, + ) && !database?.deleted, viewAllPermission: databasePermission.ViewAll, viewCustomPropertiesPermission: getPrioritizedViewPermission( databasePermission, PermissionOperation.ViewCustomFields ), - hasViewBasicPermission: getPrioritizedViewPermission( - databasePermission, - PermissionOperation.ViewBasic - ), }), [databasePermission, database] ); @@ -430,28 +489,31 @@ const DatabaseDetails: FunctionComponent = () => { [] ); - const afterDomainUpdateAction = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as Database; + const afterDomainUpdateAction = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as Database; - setDatabase((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + setDatabase((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setDatabase] + ); const tabs = useMemo(() => { const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs); const tabs = databaseClassBase.getDatabaseDetailPageTabs({ activeTab: activeTab as EntityTabs, - database, + database: database ?? ({} as Database), viewAllPermission, viewCustomPropertiesPermission, schemaInstanceCount, feedCount, handleFeedCount, getEntityFeedCount, - deleted: database.deleted ?? false, + deleted: database?.deleted ?? false, editCustomAttributePermission, getDetailsByFQN, labelMap: tabLabelMap, @@ -492,6 +554,9 @@ const DatabaseDetails: FunctionComponent = () => { } }; const followDatabase = useCallback(async () => { + if (!databaseId) { + return; + } try { const res = await addFollowers( databaseId, @@ -515,8 +580,11 @@ const DatabaseDetails: FunctionComponent = () => { }) ); } - }, [USERId, databaseId]); + }, [USERId, databaseId, followers, database, setDatabase, t]); const unfollowDatabase = useCallback(async () => { + if (!databaseId) { + return; + } try { const res = await removeFollowers( databaseId, @@ -544,7 +612,7 @@ const DatabaseDetails: FunctionComponent = () => { }) ); } - }, [USERId, database]); + }, [USERId, databaseId, database, setDatabase, t]); const handleFollowClick = useCallback(async () => { isFollowing ? await unfollowDatabase() : await followDatabase(); @@ -579,10 +647,18 @@ const DatabaseDetails: FunctionComponent = () => { [tabs[0], activeTab] ); - if (isLoading || isDatabaseDetailsLoading || loading) { + if (permissionsLoading || databaseLoading || loading) { return ; } + if (isError) { + return ( + + {getEntityMissingError(EntityType.DATABASE, decodedDatabaseFQN)} + + ); + } + if (!hasViewBasicPermission) { return ( { ); } + if (!database) { + return ; + } + return ( {isEmpty(database) ? ( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx index 30186c4ecf19..14221c7342ff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Col, Row, Skeleton, Tabs, TabsProps } from 'antd'; import { AxiosError } from 'axios'; import { compare, Operation } from 'fast-json-patch'; @@ -72,9 +73,19 @@ import { restoreDatabaseSchema, updateDatabaseSchemaVotes, } from '../../rest/databaseAPI'; +import { + databaseSchemaQueryFn, + databaseSchemaQueryKey, + DATABASE_SCHEMA_DEFAULT_FIELDS, +} from '../../rest/queries/databaseSchemaQuery'; import { getStoredProceduresList } from '../../rest/storedProceduresAPI'; import { getTableList } from '../../rest/tableAPI'; -import { getEntityMissingError, getFeedCounts } from '../../utils/CommonUtils'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getEntityMissingError, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -98,6 +109,7 @@ const DatabaseSchemaPage: FunctionComponent = () => { const { getEntityPermissionByFqn } = usePermissionProvider(); const { currentUser } = useApplicationStore(); const USERId = currentUser?.id ?? ''; + const queryClient = useQueryClient(); const { setFilters, filters } = useTableFilters(INITIAL_TABLE_FILTERS); const { tab: activeTab = EntityTabs.TABLE } = useRequiredParams<{ @@ -109,11 +121,6 @@ const DatabaseSchemaPage: FunctionComponent = () => { const navigate = useNavigate(); const [isPermissionsLoading, setIsPermissionsLoading] = useState(true); - const [databaseSchema, setDatabaseSchema] = useState( - {} as DatabaseSchema - ); - const [isSchemaDetailsLoading, setIsSchemaDetailsLoading] = - useState(true); const [feedCount, setFeedCount] = useState( FEED_COUNT_INITIAL_DATA ); @@ -127,13 +134,128 @@ const DatabaseSchemaPage: FunctionComponent = () => { const [updateProfilerSetting, setUpdateProfilerSetting] = useState(false); - const { isFollowing, followers = [] } = useMemo( - () => ({ - isFollowing: databaseSchema?.followers?.some( - ({ id }) => id === currentUser?.id + const viewDatabaseSchemaPermission = useMemo( + () => + getPrioritizedViewPermission( + databaseSchemaPermission, + PermissionOperation.ViewBasic ), - followers: databaseSchema?.followers ?? [], - }), + [databaseSchemaPermission] + ); + + const viewUsagePermission = useMemo( + () => + getPrioritizedViewPermission( + databaseSchemaPermission, + PermissionOperation.ViewUsage + ), + [databaseSchemaPermission] + ); + + const databaseSchemaFields = useMemo(() => { + // {@code DATABASE_SCHEMA_DEFAULT_FIELDS} matches the order + // {@code [OWNERS, TAGS, DOMAINS, VOTES, EXTENSION, FOLLOWERS, DATA_PRODUCTS]}; when the + // viewer has {@code ViewUsage}, we splice in {@code USAGE_SUMMARY} immediately after + // {@code OWNERS} to match the legacy fetch order the tests assert against. + if (viewUsagePermission) { + return [ + TabSpecificField.OWNERS, + TabSpecificField.USAGE_SUMMARY, + TabSpecificField.TAGS, + TabSpecificField.DOMAINS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, + TabSpecificField.FOLLOWERS, + TabSpecificField.DATA_PRODUCTS, + ].join(','); + } + + return DATABASE_SCHEMA_DEFAULT_FIELDS; + }, [viewUsagePermission]); + + const databaseSchemaCacheKey = useMemo( + () => + databaseSchemaQueryKey(decodedDatabaseSchemaFQN, databaseSchemaFields), + [decodedDatabaseSchemaFQN, databaseSchemaFields] + ); + + const { + data: databaseSchema, + isLoading: databaseSchemaLoading, + error: databaseSchemaError, + } = useQuery({ + queryKey: databaseSchemaCacheKey, + queryFn: databaseSchemaQueryFn( + decodedDatabaseSchemaFQN, + databaseSchemaFields + ), + enabled: Boolean( + decodedDatabaseSchemaFQN && + viewDatabaseSchemaPermission && + !isPermissionsLoading + ), + }); + + const isError = useMemo( + () => + (databaseSchemaError as AxiosError | undefined)?.response?.status === 404, + [databaseSchemaError] + ); + + useEffect(() => { + const status = (databaseSchemaError as AxiosError | undefined)?.response + ?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + databaseSchemaError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.database-schema'), + entityName: decodedDatabaseSchemaFQN, + }) + ); + } + }, [databaseSchemaError, navigate, decodedDatabaseSchemaFQN, t]); + + const setDatabaseSchema = useCallback( + ( + updater: + | DatabaseSchema + | undefined + | ((prev: DatabaseSchema | undefined) => DatabaseSchema | undefined) + ) => { + queryClient.setQueryData( + databaseSchemaCacheKey, + updater + ); + }, + [queryClient, databaseSchemaCacheKey] + ); + + const refetchDatabaseSchema = useCallback( + () => queryClient.invalidateQueries({ queryKey: databaseSchemaCacheKey }), + [queryClient, databaseSchemaCacheKey] + ); + + const fetchDatabaseSchemaDetails = useCallback( + () => refetchDatabaseSchema(), + [refetchDatabaseSchema] + ); + + // Sync filters when the fetched schema is deleted. + useEffect(() => { + if (databaseSchema?.deleted) { + setFilters({ + showDeletedTables: databaseSchema.deleted, + }); + } + }, [databaseSchema?.deleted]); + + const isFollowing = useMemo( + () => + databaseSchema?.followers?.some(({ id }) => id === currentUser?.id) ?? + false, [currentUser, databaseSchema] ); const extraDropdownContent = useMemo( @@ -142,7 +264,7 @@ const DatabaseSchemaPage: FunctionComponent = () => { EntityType.DATABASE_SCHEMA, decodedDatabaseSchemaFQN, databaseSchemaPermission, - databaseSchema, + databaseSchema ?? ({} as DatabaseSchema), navigate ), [ @@ -153,7 +275,7 @@ const DatabaseSchemaPage: FunctionComponent = () => { ); const { version: currentVersion, id: databaseSchemaId = '' } = useMemo( - () => databaseSchema, + () => databaseSchema ?? ({} as DatabaseSchema), [databaseSchema] ); @@ -172,24 +294,6 @@ const DatabaseSchemaPage: FunctionComponent = () => { } }, [decodedDatabaseSchemaFQN]); - const viewDatabaseSchemaPermission = useMemo( - () => - getPrioritizedViewPermission( - databaseSchemaPermission, - PermissionOperation.ViewBasic - ), - [databaseSchemaPermission] - ); - - const viewUsagePermission = useMemo( - () => - getPrioritizedViewPermission( - databaseSchemaPermission, - PermissionOperation.ViewUsage - ), - [databaseSchemaPermission] - ); - const handleFeedCount = useCallback((data: FeedCounts) => { setFeedCount(data); }, []); @@ -202,40 +306,21 @@ const DatabaseSchemaPage: FunctionComponent = () => { ); }, [decodedDatabaseSchemaFQN, handleFeedCount]); - const fetchDatabaseSchemaDetails = useCallback(async () => { - try { - setIsSchemaDetailsLoading(true); - const response = await getDatabaseSchemaDetailsByFQN( + const fetchTaskCounts = useCallback(() => { + if (decodedDatabaseSchemaFQN) { + fetchEntityTaskCountsInto(decodedDatabaseSchemaFQN, setFeedCount); + } + }, [decodedDatabaseSchemaFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedDatabaseSchemaFQN) { + fetchEntityActivityCountInto( + EntityType.DATABASE_SCHEMA, decodedDatabaseSchemaFQN, - { - fields: [ - TabSpecificField.OWNERS, - ...(viewUsagePermission ? [TabSpecificField.USAGE_SUMMARY] : []), - TabSpecificField.TAGS, - TabSpecificField.DOMAINS, - TabSpecificField.VOTES, - TabSpecificField.EXTENSION, - TabSpecificField.FOLLOWERS, - TabSpecificField.DATA_PRODUCTS, - ].join(','), - include: Include.All, - } + setFeedCount ); - setDatabaseSchema(response); - if (response.deleted) { - setFilters({ - showDeletedTables: response.deleted, - }); - } - } catch (err) { - // Error - if ((err as AxiosError)?.response?.status === ClientErrors.FORBIDDEN) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } - } finally { - setIsSchemaDetailsLoading(false); } - }, [decodedDatabaseSchemaFQN, viewUsagePermission]); + }, [decodedDatabaseSchemaFQN]); const saveUpdatedDatabaseSchemaData = useCallback( (updatedData: DatabaseSchema) => { @@ -269,6 +354,9 @@ const DatabaseSchemaPage: FunctionComponent = () => { const handleUpdateOwner = useCallback( async (owners: DatabaseSchema['owners']) => { + if (!databaseSchema) { + return; + } try { const updatedData = { ...databaseSchema, @@ -289,11 +377,14 @@ const DatabaseSchemaPage: FunctionComponent = () => { ); } }, - [databaseSchema, databaseSchema?.owners] + [databaseSchema, saveUpdatedDatabaseSchemaData, setDatabaseSchema, t] ); const handleUpdateTier = useCallback( async (newTier?: Tag) => { + if (!databaseSchema) { + return; + } const tierTag = updateTierTag(databaseSchema?.tags ?? [], newTier); const updatedSchemaDetails = { ...databaseSchema, @@ -305,7 +396,7 @@ const DatabaseSchemaPage: FunctionComponent = () => { ); setDatabaseSchema(res); }, - [saveUpdatedDatabaseSchemaData, databaseSchema] + [saveUpdatedDatabaseSchemaData, databaseSchema, setDatabaseSchema] ); const handleUpdateDisplayName = useCallback( @@ -322,30 +413,33 @@ const DatabaseSchemaPage: FunctionComponent = () => { showErrorToast(error as AxiosError, t('server.api-error')); } }, - [databaseSchema, saveUpdatedDatabaseSchemaData] + [databaseSchema, saveUpdatedDatabaseSchemaData, setDatabaseSchema, t] ); - const handleToggleDelete = (version?: number) => { - navigate('', { - state: { - cursorData: null, - pageSize: null, - currentPage: INITIAL_PAGING_VALUE, - replace: true, - }, - }); - setDatabaseSchema((prev) => { - if (!prev) { - return prev; - } + const handleToggleDelete = useCallback( + (version?: number) => { + navigate('', { + state: { + cursorData: null, + pageSize: null, + currentPage: INITIAL_PAGING_VALUE, + replace: true, + }, + }); + setDatabaseSchema((prev) => { + if (!prev) { + return prev; + } - return { - ...prev, - deleted: !prev?.deleted, - ...(version ? { version } : {}), - }; - }); - }; + return { + ...prev, + deleted: !prev?.deleted, + ...(version ? { version } : {}), + }; + }); + }, + [navigate, setDatabaseSchema] + ); const handleRestoreDatabaseSchema = useCallback(async () => { try { @@ -366,7 +460,7 @@ const DatabaseSchemaPage: FunctionComponent = () => { }) ); } - }, [databaseSchemaId]); + }, [databaseSchemaId, handleToggleDelete]); const versionHandler = useCallback(() => { currentVersion && @@ -385,14 +479,17 @@ const DatabaseSchemaPage: FunctionComponent = () => { [] ); - const afterDomainUpdateAction = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as DatabaseSchema; + const afterDomainUpdateAction = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as DatabaseSchema; - setDatabaseSchema((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + setDatabaseSchema((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setDatabaseSchema] + ); // Fetch stored procedure count to show it in Tab label const fetchStoreProcedureCount = useCallback(async () => { @@ -428,15 +525,15 @@ const DatabaseSchemaPage: FunctionComponent = () => { useEffect(() => { if (viewDatabaseSchemaPermission) { - fetchDatabaseSchemaDetails(); fetchStoreProcedureCount(); - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); } }, [ viewDatabaseSchemaPermission, - fetchDatabaseSchemaDetails, fetchStoreProcedureCount, - getEntityFeedCount, + fetchTaskCounts, + fetchActivityCount, ]); useEffect(() => { @@ -453,7 +550,7 @@ const DatabaseSchemaPage: FunctionComponent = () => { getPrioritizedEditPermission( databaseSchemaPermission, PermissionOperation.EditCustomFields - ) && !databaseSchema.deleted, + ) && !databaseSchema?.deleted, viewAllPermission: databaseSchemaPermission.ViewAll, viewCustomPropertiesPermission: getPrioritizedViewPermission( databaseSchemaPermission, @@ -464,6 +561,9 @@ const DatabaseSchemaPage: FunctionComponent = () => { ); const handleExtensionUpdate = async (schema: DatabaseSchema) => { + if (!databaseSchema) { + return; + } const response = await saveUpdatedDatabaseSchemaData({ ...databaseSchema, extension: schema.extension, @@ -563,60 +663,92 @@ const DatabaseSchemaPage: FunctionComponent = () => { checkIfExpandViewSupported(tabs[0], activeTab, PageType.DatabaseSchema), [tabs[0], activeTab] ); - const followSchema = useCallback(async () => { - try { - const res = await addFollowers( - databaseSchemaId, - USERId ?? '', - GlobalSettingOptions.DATABASE_SCHEMA - ); - const { newValue } = res.changeDescription.fieldsAdded[0]; - const newFollowers = [...(followers ?? []), ...newValue]; - setDatabaseSchema((prev) => { - if (!prev) { - return prev; - } - return { ...prev, followers: newFollowers }; - }); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(databaseSchema), - }) - ); - } - }, [USERId, databaseSchemaId]); - const unFollowSchema = useCallback(async () => { - try { - const res = await removeFollowers( - databaseSchemaId, - USERId, - GlobalSettingOptions.DATABASE_SCHEMA + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: DatabaseSchema | undefined } + >({ + mutationFn: async () => { + if (!databaseSchemaId) { + return; + } + if (isFollowing) { + await removeFollowers( + databaseSchemaId, + USERId, + GlobalSettingOptions.DATABASE_SCHEMA + ); + } else { + await addFollowers( + databaseSchemaId, + USERId, + GlobalSettingOptions.DATABASE_SCHEMA + ); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: databaseSchemaCacheKey }); + const previous = queryClient.getQueryData( + databaseSchemaCacheKey ); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setDatabaseSchema((pre) => { - if (!pre) { - return pre; + queryClient.setQueryData( + databaseSchemaCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } + + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as DatabaseSchema['followers'], + }; } + ); - return { - ...pre, - followers: pre.followers?.filter( - (follower) => follower.id !== oldValue[0].id - ), - }; - }); - } catch (error) { + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + databaseSchemaCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(databaseSchema), - }) + isFollowing + ? t('server.entity-unfollow-error', { + entity: getEntityName(databaseSchema), + }) + : t('server.entity-follow-error', { + entity: getEntityName(databaseSchema), + }) ); - } - }, [USERId, databaseSchemaId]); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: databaseSchemaCacheKey }); + }, + }); + + const followSchema = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); + + const unFollowSchema = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const onCertificationUpdate = useCallback( async (newCertification?: Tag) => { @@ -642,6 +774,17 @@ const DatabaseSchemaPage: FunctionComponent = () => { return ; } + if (isError) { + return ( + + {getEntityMissingError( + EntityType.DATABASE_SCHEMA, + decodedDatabaseSchemaFQN + )} + + ); + } + if (!viewDatabaseSchemaPermission) { return ( { return ( - {isEmpty(databaseSchema) && !isSchemaDetailsLoading ? ( + {isEmpty(databaseSchema) && !databaseSchemaLoading ? ( {getEntityMissingError( EntityType.DATABASE_SCHEMA, @@ -666,7 +809,7 @@ const DatabaseSchemaPage: FunctionComponent = () => { ) : ( - {isSchemaDetailsLoading ? ( + {databaseSchemaLoading || !databaseSchema ? ( { customizedPage={customizedPage} - data={databaseSchema} + data={databaseSchema ?? ({} as DatabaseSchema)} isTabExpanded={isTabExpanded} permissions={databaseSchemaPermission} type={EntityType.DATABASE_SCHEMA} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.test.tsx index 265cef030a29..be5b58f751d4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.test.tsx @@ -11,13 +11,14 @@ * limitations under the License. */ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { FEED_COUNT_INITIAL_DATA } from '../../constants/entity.constants'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { getDatabaseSchemaDetailsByFQN } from '../../rest/databaseAPI'; import { getStoredProceduresList } from '../../rest/storedProceduresAPI'; -import { getFeedCounts } from '../../utils/CommonUtils'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; +import { fetchEntityTaskCountsInto } from '../../utils/CommonUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import DatabaseSchemaPageComponent from './DatabaseSchemaPage.component'; import { @@ -119,6 +120,8 @@ jest.mock('../../rest/tableAPI', () => ({ })); jest.mock('../../utils/CommonUtils', () => ({ + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getEntityMissingError: jest.fn().mockImplementation((error) => error), getFeedCounts: jest.fn().mockImplementation(() => FEED_COUNT_INITIAL_DATA), sortTagsCaseInsensitive: jest.fn(), @@ -276,7 +279,7 @@ jest.mock( describe('Tests for DatabaseSchemaPage', () => { it('DatabaseSchemaPage should fetch permissions', () => { - render(); + renderWithQueryClient(); expect(mockEntityPermissionByFqn).toHaveBeenCalledWith( 'databaseSchema', @@ -285,7 +288,7 @@ describe('Tests for DatabaseSchemaPage', () => { }); it('DatabaseSchemaPage should not fetch details if permission is there', () => { - render(); + renderWithQueryClient(); expect(getDatabaseSchemaDetailsByFQN).not.toHaveBeenCalled(); expect(getStoredProceduresList).not.toHaveBeenCalled(); @@ -299,7 +302,7 @@ describe('Tests for DatabaseSchemaPage', () => { })); await act(async () => { - render(); + renderWithQueryClient(); }); expect(await screen.findByText('ErrorPlaceHolder')).toBeInTheDocument(); @@ -313,7 +316,7 @@ describe('Tests for DatabaseSchemaPage', () => { })); await act(async () => { - render(); + renderWithQueryClient(); }); expect(getDatabaseSchemaDetailsByFQN).toHaveBeenCalledWith(mockParams.fqn, { @@ -330,7 +333,7 @@ describe('Tests for DatabaseSchemaPage', () => { })); await act(async () => { - render(); + renderWithQueryClient(); }); expect(getStoredProceduresList).toHaveBeenCalledWith({ @@ -346,7 +349,7 @@ describe('Tests for DatabaseSchemaPage', () => { }), })); - render(); + renderWithQueryClient(); await waitFor(() => { expect(getDatabaseSchemaDetailsByFQN).toHaveBeenCalledWith( @@ -370,7 +373,7 @@ describe('Tests for DatabaseSchemaPage', () => { }), })); - render(); + renderWithQueryClient(); await waitFor(() => { expect(getDatabaseSchemaDetailsByFQN).toHaveBeenCalledWith( @@ -398,7 +401,7 @@ describe('Tests for DatabaseSchemaPage', () => { }), })); - const { rerender } = render(); + const { rerender } = renderWithQueryClient(); // Wait for initial API calls await waitFor(() => { @@ -410,8 +413,7 @@ describe('Tests for DatabaseSchemaPage', () => { databaseSchema: 'sample_data.ecommerce_db.shopify', limit: 0, }); - expect(getFeedCounts).toHaveBeenCalledWith( - 'databaseSchema', + expect(fetchEntityTaskCountsInto).toHaveBeenCalledWith( 'sample_data.ecommerce_db.shopify', expect.any(Function) ); @@ -437,8 +439,7 @@ describe('Tests for DatabaseSchemaPage', () => { databaseSchema: 'Glue.default.information_schema', limit: 0, }); - expect(getFeedCounts).toHaveBeenCalledWith( - 'databaseSchema', + expect(fetchEntityTaskCountsInto).toHaveBeenCalledWith( 'Glue.default.information_schema', expect.any(Function) ); @@ -462,7 +463,7 @@ describe('Tests for DatabaseSchemaPage', () => { })); await act(async () => { - render(); + renderWithQueryClient(); }); expect(PageLayoutV1).toHaveBeenCalledWith( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage.tsx index 5b692320da80..f84361ee6f59 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage.tsx @@ -14,13 +14,13 @@ import { Button, Card, Col, Progress, Row, Space, Typography } from 'antd'; import { AxiosError } from 'axios'; import { capitalize, isEmpty, startCase } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import DataGrid, { Column, ColumnOrColumnGroup } from 'react-data-grid'; -import 'react-data-grid/lib/styles.css'; +import type { Column, ColumnOrColumnGroup } from 'react-data-grid'; import { useTranslation } from 'react-i18next'; import { usePapaParse } from 'react-papaparse'; import { useLocation, useNavigate } from 'react-router-dom'; import BulkEditEntity from '../../../components/BulkEditEntity/BulkEditEntity.component'; import Banner from '../../../components/common/Banner/Banner'; +import { LazyDataGrid } from '../../../components/common/DataGrid/LazyDataGrid'; import { ImportStatus } from '../../../components/common/EntityImport/ImportStatus/ImportStatus.component'; import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { TitleBreadcrumbProps } from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface'; @@ -624,7 +624,7 @@ const BulkEntityImportPage = () => { const editDataGrid = useMemo(() => { return (
- { {validateCSVData && (
- = () => { const [searchResults, setSearchResults] = useState>(); + const [tourSearchResults, setTourSearchResults] = + useState>(); + const [showIndexNotFoundAlert, setShowIndexNotFoundAlert] = useState(false); @@ -333,10 +332,17 @@ const ExplorePageV1: FC = () => { } }; - // Effect for handling tour + // Effect for handling tour — lazy-load the ~113 KB mock dataset only when the tour is open. useEffect(() => { if (isTourOpen) { - setSearchHitCounts(MOCK_EXPLORE_PAGE_COUNT); + import('../../constants/mockTourData.constants').then( + ({ mockSearchData, MOCK_EXPLORE_PAGE_COUNT }) => { + setSearchHitCounts(MOCK_EXPLORE_PAGE_COUNT); + setTourSearchResults( + mockSearchData as unknown as SearchResponse + ); + } + ); } }, [isTourOpen]); @@ -388,11 +394,7 @@ const ExplorePageV1: FC = () => { loading={isLoading && !isTourOpen} quickFilters={advancedSearchQuickFilters} searchIndex={searchIndex} - searchResults={ - isTourOpen - ? (mockSearchData as unknown as SearchResponse) - : searchResults - } + searchResults={isTourOpen ? tourSearchResults : searchResults} showDeleted={showDeleted} sortOrder={sortOrder} sortValue={sortValue} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx index 2e65a3e21d80..4abafe61a28a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/Glossary/GlossaryPage/GlossaryPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isEmpty } from 'lodash'; @@ -52,12 +53,17 @@ import { useElementInView } from '../../../hooks/useElementInView'; import { useFqn } from '../../../hooks/useFqn'; import { getGlossariesList, - getGlossaryTermByFQN, patchGlossaries, patchGlossaryTerm, updateGlossaryTermVotes, updateGlossaryVotes, } from '../../../rest/glossaryAPI'; +import { + glossaryTermQueryFn, + glossaryTermQueryKey, + GLOSSARY_TERM_DEFAULT_FIELDS, +} from '../../../rest/queries/glossaryTermQuery'; +import { getEntityMissingError } from '../../../utils/CommonUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import Fqn from '../../../utils/Fqn'; import { checkPermission } from '../../../utils/PermissionsUtils'; @@ -71,6 +77,7 @@ const GlossaryPage = () => { const { fqn: glossaryFqn } = useFqn(); const { t } = useTranslation(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { handleOnAsyncEntityDeleteConfirm } = useAsyncDeleteProvider(); const { action } = useRequiredParams<{ action: EntityAction }>(); const [initialised, setInitialised] = useState(false); @@ -85,7 +92,6 @@ const GlossaryPage = () => { }); const { paging, pageSize, handlePagingChange } = usePaging(); - const [isRightPanelLoading, setIsRightPanelLoading] = useState(true); const [previewAsset, setPreviewAsset] = useState(); @@ -103,8 +109,6 @@ const GlossaryPage = () => { ); const isGlossaryActive = useMemo(() => { - setIsRightPanelLoading(true); - if (glossaryFqn) { return Fqn.split(glossaryFqn).length === 1; } @@ -232,55 +236,94 @@ const GlossaryPage = () => { } }, [paging, isInView, isMoreGlossaryLoading, pageSize]); - const fetchGlossaryTermDetails = useCallback(async () => { - setIsRightPanelLoading(true); - try { - const response = await getGlossaryTermByFQN(glossaryFqn, { - fields: [ - TabSpecificField.RELATED_TERMS, - TabSpecificField.REVIEWERS, - TabSpecificField.TAGS, - TabSpecificField.OWNERS, - TabSpecificField.CHILDREN, - TabSpecificField.VOTES, - TabSpecificField.DOMAINS, - TabSpecificField.EXTENSION, - TabSpecificField.CHILDREN_COUNT, - ], - }); - setActiveGlossary(response as ModifiedGlossary); - } catch (error) { - if ((error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } - } finally { - setIsRightPanelLoading(false); + const glossaryTermCacheKey = useMemo( + () => glossaryTermQueryKey(glossaryFqn, GLOSSARY_TERM_DEFAULT_FIELDS), + [glossaryFqn] + ); + + const isTermView = !isGlossaryActive && Boolean(glossaryFqn); + + const { + data: glossaryTermDetails, + isFetching: glossaryTermFetching, + error: glossaryTermError, + } = useQuery({ + queryKey: glossaryTermCacheKey, + queryFn: glossaryTermQueryFn(glossaryFqn, GLOSSARY_TERM_DEFAULT_FIELDS), + enabled: isTermView, + }); + + const setGlossaryTermDetails = useCallback( + ( + updater: + | GlossaryTerm + | undefined + | ((prev: GlossaryTerm | undefined) => GlossaryTerm | undefined) + ) => { + queryClient.setQueryData( + glossaryTermCacheKey, + updater + ); + }, + [queryClient, glossaryTermCacheKey] + ); + + const refetchActiveGlossaryTerm = useCallback( + () => queryClient.invalidateQueries({ queryKey: glossaryTermCacheKey }), + [queryClient, glossaryTermCacheKey] + ); + + useEffect(() => { + const status = (glossaryTermError as AxiosError | undefined)?.response + ?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); } - }, [glossaryFqn]); + }, [glossaryTermError, navigate]); + // Sync the fetched term into the Zustand store consumed by {@code GlossaryV1}. The + // store is also written to by the glossary-list code path below, so the two writers + // share a single sink rather than the component branching on isGlossaryActive twice. useEffect(() => { - setIsRightPanelLoading(true); - if (glossaries.length) { - if (!isGlossaryActive) { - fetchGlossaryTermDetails(); - } else { - setActiveGlossary( - glossaries.find( - (glossary) => glossary.fullyQualifiedName === glossaryFqn - ) || glossaries[0] - ); + if (isTermView && glossaryTermDetails) { + setActiveGlossary(glossaryTermDetails as ModifiedGlossary); + } + }, [isTermView, glossaryTermDetails, setActiveGlossary]); - if (isEmpty(glossaryFqn) && glossaries[0].fullyQualifiedName) { - navigate(getGlossaryPath(glossaries[0].fullyQualifiedName), { - replace: true, - }); - } + useEffect(() => { + if (glossaries.length && isGlossaryActive) { + setActiveGlossary( + glossaries.find( + (glossary) => glossary.fullyQualifiedName === glossaryFqn + ) || glossaries[0] + ); - setIsRightPanelLoading(false); + if (isEmpty(glossaryFqn) && glossaries[0].fullyQualifiedName) { + navigate(getGlossaryPath(glossaries[0].fullyQualifiedName), { + replace: true, + }); } } }, [isGlossaryActive, glossaryFqn, glossaries]); + const isRightPanelLoading = useMemo(() => { + if (!glossaries.length) { + return true; + } + if (isTermView) { + return glossaryTermFetching; + } + + return false; + }, [glossaries.length, isTermView, glossaryTermFetching]); + + const isTermNotFound = useMemo( + () => + isTermView && + (glossaryTermError as AxiosError | undefined)?.response?.status === 404, + [isTermView, glossaryTermError] + ); + const updateGlossary = useCallback( async (updatedData: Glossary) => { const jsonPatch = compare(activeGlossary as Glossary, updatedData); @@ -374,12 +417,13 @@ const GlossaryPage = () => { const response = await patchGlossaryTerm(activeGlossary?.id, jsonPatch); if (response) { setActiveGlossary(response as ModifiedGlossary); + setGlossaryTermDetails(response); if (activeGlossary?.name !== updatedData.name) { navigate(getGlossaryPath(response.fullyQualifiedName)); fetchGlossaryList(); } if (shouldRefreshTerms) { - fetchGlossaryTermDetails(); + refetchActiveGlossaryTerm(); } } else { throw t('server.entity-updating-error', { @@ -390,7 +434,7 @@ const GlossaryPage = () => { showErrorToast(error as AxiosError); } }, - [activeGlossary] + [activeGlossary, setGlossaryTermDetails, refetchActiveGlossaryTerm] ); const handleGlossaryTermDelete = useCallback( @@ -480,24 +524,33 @@ const GlossaryPage = () => { ); } - const glossaryElement = isRightPanelLoading ? ( - - ) : ( - - ); + let glossaryElement; + if (isRightPanelLoading) { + glossaryElement = ; + } else if (isTermNotFound) { + glossaryElement = ( + + {getEntityMissingError(t('label.glossary-term'), glossaryFqn)} + + ); + } else { + glossaryElement = ( + + ); + } const resizableLayout = isGlossaryActive ? ( { it('GlossaryComponent Page Should render', async () => { - render(); + renderWithQueryClient(); const glossaryComponent = await screen.findByText(/Glossary.component/i); @@ -186,7 +187,7 @@ describe('Test GlossaryComponent page', () => { }); it('All Function call should work properly - part 1', async () => { - render(); + renderWithQueryClient(); const glossaryComponent = await screen.findByText(/Glossary.component/i); @@ -198,7 +199,7 @@ describe('Test GlossaryComponent page', () => { }); it('All Function call should work properly - part 2', async () => { - render(); + renderWithQueryClient(); const glossaryComponent = await screen.findByText(/Glossary.component/i); @@ -220,7 +221,7 @@ describe('Test GlossaryComponent page', () => { (patchGlossaryTerm as jest.Mock).mockImplementation(() => Promise.resolve({ data: '' }) ); - render(); + renderWithQueryClient(); const handleGlossaryTermUpdate = await screen.findByTestId( 'handleGlossaryTermUpdate' ); @@ -251,7 +252,7 @@ describe('Test GlossaryComponent page', () => { updateActiveGlossary: mockUpdateActiveGlossary, })); - render(); + renderWithQueryClient(); const handleGlossaryDelete = await screen.findByTestId( 'handleGlossaryDelete' @@ -276,7 +277,7 @@ describe('Test GlossaryComponent page', () => { updateActiveGlossary: mockUpdateActiveGlossary, })); - render(); + renderWithQueryClient(); const handleGlossaryDelete = await screen.findByTestId( 'handleGlossaryDelete' @@ -319,7 +320,7 @@ describe('Test GlossaryComponent page', () => { updateActiveGlossary: mockUpdateActiveGlossary, })); - render(); + renderWithQueryClient(); const handleGlossaryDelete = await screen.findByTestId( 'handleGlossaryDelete' @@ -339,7 +340,7 @@ describe('Test GlossaryComponent page', () => { it('should pass entity name as pageTitle to withPageLayout', async () => { await act(async () => { - render(); + renderWithQueryClient(); }); expect(ResizableLeftPanels).toHaveBeenCalledWith( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.test.tsx index 5a8e50a702a8..59f93dfbcddf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.test.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { TestCase } from '../../../generated/tests/testCase'; @@ -182,11 +183,24 @@ jest.mock('@mui/material', () => ({ )), })); -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); +const Wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + mutations: { retry: false }, + }, + }); + + return ( + + + + {children} + + + + ); +}; describe('IncidentManagerDetailPage', () => { it('should render component', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.tsx index 78d6116105b4..f9729cfac71e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/IncidentManager/IncidentManagerDetailPage/IncidentManagerDetailPage.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ import Icon from '@ant-design/icons'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Button, Col, Row, Tabs, TabsProps, Tooltip, Typography } from 'antd'; import ButtonGroup from 'antd/lib/button/button-group'; import { AxiosError } from 'axios'; @@ -47,17 +48,24 @@ import { EntityTabs, EntityType } from '../../../enums/entity.enum'; import { ChangeDescription, EntityReference, + TestCase, } from '../../../generated/tests/testCase'; import { EntityHistory } from '../../../generated/type/entityHistory'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { - getTestCaseByFqn, + testCaseQueryFn, + testCaseQueryKey, +} from '../../../rest/queries/incidentManagerQuery'; +import { getTestCaseVersionDetails, getTestCaseVersionList, updateTestCaseById, } from '../../../rest/testAPI'; -import { getFeedCounts } from '../../../utils/CommonUtils'; +import { + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../../utils/CommonUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import { getEntityVersionByField } from '../../../utils/EntityVersionUtils'; import observabilityRouterClassBase from '../../../utils/ObservabilityRouterClassBase'; @@ -76,6 +84,7 @@ const IncidentManagerDetailPage = ({ const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); + const queryClient = useQueryClient(); const { tab: activeTab = TestCasePageTabs.TEST_CASE_RESULTS, @@ -91,7 +100,6 @@ const IncidentManagerDetailPage = ({ const isDimensionPage = Boolean(dimensionKey); const { - isLoading, setIsLoading, setTestCase, testCase, @@ -129,6 +137,70 @@ const IncidentManagerDetailPage = ({ }; }, [testCasePermission]); + const testCaseFields = useMemo(() => testCaseClassBase.getFields(), []); + + const testCaseCacheKey = useMemo( + () => testCaseQueryKey(testCaseFQN, testCaseFields), + [testCaseFQN, testCaseFields] + ); + + const { + data: testCaseData, + isLoading: testCaseLoading, + error: testCaseError, + } = useQuery({ + queryKey: testCaseCacheKey, + queryFn: testCaseQueryFn(testCaseFQN, testCaseFields), + enabled: Boolean(testCaseFQN && hasViewPermission && !isPermissionLoading), + }); + + const setEntityDetails = useCallback( + ( + updater: + | TestCase + | undefined + | ((prev: TestCase | undefined) => TestCase | undefined) + ) => { + queryClient.setQueryData(testCaseCacheKey, updater); + }, + [queryClient, testCaseCacheKey] + ); + + // Mirror query data into the Zustand store so child components + // (TestCaseResultTab, IncidentTab, page header) — which read directly from + // {@code useTestCaseStore} — continue to receive updates without having to + // be migrated to React Query themselves. + useEffect(() => { + if (testCaseData) { + testCaseClassBase.setShowSqlQueryTab( + !isUndefined(testCaseData.inspectionQuery) + ); + setTestCase(testCaseData); + } + }, [testCaseData, setTestCase]); + + useEffect(() => { + setIsLoading(testCaseLoading); + }, [testCaseLoading, setIsLoading]); + + useEffect(() => { + if (testCaseError) { + showErrorToast( + testCaseError as AxiosError, + t('server.entity-fetch-error', { entity: t('label.test-case') }) + ); + } + }, [testCaseError, t]); + + useEffect(() => { + if (!isVersionPage || !testCaseData?.id) { + return; + } + getTestCaseVersionList(testCaseData.id) + .then(setVersionList) + .catch((error) => showErrorToast(error as AxiosError)); + }, [isVersionPage, testCaseData?.id]); + const isExpandViewSupported = useMemo( () => activeTab === TestCasePageTabs.TEST_CASE_RESULTS, [activeTab] @@ -179,30 +251,6 @@ const IncidentManagerDetailPage = ({ } }; - const fetchTestCaseData = async () => { - setIsLoading(true); - try { - const response = await getTestCaseByFqn(testCaseFQN, { - fields: testCaseClassBase.getFields(), - }); - testCaseClassBase.setShowSqlQueryTab( - !isUndefined(response.inspectionQuery) - ); - if (isVersionPage) { - const versionResponse = await getTestCaseVersionList(response.id ?? ''); - setVersionList(versionResponse); - } - setTestCase(response); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-fetch-error', { entity: t('label.test-case') }) - ); - } finally { - setIsLoading(false); - } - }; - const breadcrumb = useMemo(() => { const data: TitleBreadcrumbProps['titleLinks'] = location.state ?.breadcrumbData @@ -267,14 +315,17 @@ const IncidentManagerDetailPage = ({ ); } }; - const updateTestCase = async (id: string, patch: PatchOperation[]) => { - try { - const res = await updateTestCaseById(id, patch); - setTestCase(res); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; + const updateTestCase = useCallback( + async (id: string, patch: PatchOperation[]) => { + try { + const res = await updateTestCaseById(id, patch); + setEntityDetails(res); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, + [setEntityDetails] + ); const handleOwnerChange = async (owners?: EntityReference[]) => { if (testCase) { const updatedTestCase = { @@ -315,6 +366,16 @@ const IncidentManagerDetailPage = ({ getFeedCounts(EntityType.TEST_CASE, testCaseFQN, handleFeedCount); }, [testCaseFQN]); + // P2-A: only `feedCount.openTaskCount` is consumed by this page (drives the tabs' open-task + // badge in `tabDetails` useMemo). The activity-events fetch that {@link getFeedCounts} + // bundles in is wasted here, so we skip it entirely — there's no Activity Feed tab on + // incidents (TestCasePageTabs). + const fetchTaskCounts = useCallback(() => { + if (testCaseFQN) { + fetchEntityTaskCountsInto(testCaseFQN, setFeedCount); + } + }, [testCaseFQN]); + const handleCancelDimension = useCallback( () => setIsDimensionEdit(false), [] @@ -372,13 +433,9 @@ const IncidentManagerDetailPage = ({ useEffect(() => { if (hasViewPermission && testCaseFQN) { - fetchTestCaseData(); - getEntityFeedCount(); - } else { - setIsLoading(false); + fetchTaskCounts(); } - // Cleanup function for unmount return () => { reset(); testCaseClassBase.setShowSqlQueryTab(false); @@ -414,7 +471,7 @@ const IncidentManagerDetailPage = ({ ]; }, [t, hasEditPermission, isVersionPage, testCase?.entityLink]); - if (isLoading || isPermissionLoading) { + if (isPermissionLoading || testCaseLoading) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/LogsViewerPage/LogsViewerPage.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/LogsViewerPage/LogsViewerPage.component.test.tsx index eb892f4138f6..310155e8f3c9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/LogsViewerPage/LogsViewerPage.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/LogsViewerPage/LogsViewerPage.component.test.tsx @@ -181,6 +181,13 @@ jest.mock('../../hooks/useDownloadProgressStore', () => ({ })), })); +jest.mock('../../hooks/useScheduleDescriptionTexts', () => ({ + useScheduleDescriptionTexts: jest.fn().mockReturnValue({ + descriptionFirstPart: 'Every day', + descriptionSecondPart: 'at 12:00 AM', + }), +})); + let mockScrollPosition = { scrollTop: 80, scrollHeight: 100, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/LogsViewerPage/LogsViewerPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/LogsViewerPage/LogsViewerPage.tsx index 95895b93b37f..c7c2b7874e73 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/LogsViewerPage/LogsViewerPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/LogsViewerPage/LogsViewerPage.tsx @@ -57,6 +57,7 @@ import { Include } from '../../generated/type/include'; import { Paging } from '../../generated/type/paging'; import { useDownloadProgressStore } from '../../hooks/useDownloadProgressStore'; import { useFqn } from '../../hooks/useFqn'; +import { useScheduleDescriptionTexts } from '../../hooks/useScheduleDescriptionTexts'; import { getApplicationByName, getExternalApplicationRuns, @@ -67,10 +68,7 @@ import { getIngestionPipelineLogById, } from '../../rest/ingestionPipelineAPI'; import { ExtraInfoLabel } from '../../utils/DataAssetsHeader.utils'; -import { - getEpochMillisForPastDays, - getScheduleDescriptionTexts, -} from '../../utils/date-time/DateTimeUtils'; +import { getEpochMillisForPastDays } from '../../utils/date-time/DateTimeUtils'; import { getEntityName } from '../../utils/EntityUtils'; import { downloadAppLogs, @@ -82,6 +80,46 @@ import { useRequiredParams } from '../../utils/useRequiredParams'; import './logs-viewer-page.style.less'; import { LogViewerParams } from './LogsViewerPage.interfaces'; +const ScheduleSummaryValue = ({ + cronExpression, +}: { + cronExpression: string; +}) => { + const theme = useTheme(); + const { descriptionFirstPart, descriptionSecondPart } = + useScheduleDescriptionTexts(cronExpression); + + return ( + + + + + {descriptionFirstPart} + + + {descriptionSecondPart} + + + + ); +}; + const LogsViewerPage = () => { const { logEntityType } = useRequiredParams(); const { fqn: ingestionName } = useFqn(); @@ -89,7 +127,6 @@ const LogsViewerPage = () => { const runId = searchParams.get('runId') ?? undefined; const { t } = useTranslation(); - const theme = useTheme(); const { progress, reset, updateProgress } = useDownloadProgressStore(); const [isLoading, setIsLoading] = useState(false); const [logs, setLogs] = useState(''); @@ -397,42 +434,14 @@ const LogsViewerPage = () => { divider={} spacing={2}> {Object.entries(logSummaries).map(([key, value]) => { - let valueText = value; - - if (key === 'Schedule') { - const { descriptionFirstPart, descriptionSecondPart } = - getScheduleDescriptionTexts((value ?? '') as string); - - valueText = ( - - - - - {descriptionFirstPart} - - - {descriptionSecondPart} - - - + const valueText = + key === 'Schedule' ? ( + + ) : ( + value ); - } return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricDetailsPage/MetricDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricDetailsPage/MetricDetailsPage.tsx index 1407e96ca81b..fd9d710b3ec6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricDetailsPage/MetricDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MetricsPage/MetricDetailsPage/MetricDetailsPage.tsx @@ -11,10 +11,11 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { isUndefined, omitBy, toString } from 'lodash'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -31,18 +32,22 @@ import { } from '../../../context/PermissionProvider/PermissionProvider.interface'; import { ClientErrors } from '../../../enums/Axios.enum'; import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; -import { EntityType, TabSpecificField } from '../../../enums/entity.enum'; +import { EntityType } from '../../../enums/entity.enum'; import { Metric } from '../../../generated/entity/data/metric'; import { Operation } from '../../../generated/entity/policies/accessControl/resourcePermission'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { useFqn } from '../../../hooks/useFqn'; import { addMetricFollower, - getMetricByFqn, patchMetric, removeMetricFollower, updateMetricVote, } from '../../../rest/metricsAPI'; +import { + metricQueryFn, + metricQueryKey, + METRIC_DEFAULT_FIELDS, +} from '../../../rest/queries/metricQuery'; import { addToRecentViewed, getEntityMissingError, @@ -61,53 +66,99 @@ const MetricDetailsPage = () => { const currentUserId = currentUser?.id ?? ''; const navigate = useNavigate(); const { getEntityPermissionByFqn } = usePermissionProvider(); + const queryClient = useQueryClient(); const { fqn: metricFqn } = useFqn(); - const [metricDetails, setMetricDetails] = useState({} as Metric); - const [isLoading, setLoading] = useState(true); - const [isError, setIsError] = useState(false); + const [permissionsLoading, setPermissionsLoading] = useState(true); const [metricPermissions, setMetricPermissions] = useState(DEFAULT_ENTITY_PERMISSION); - const { id: metricId, version: currentVersion } = metricDetails; + const canViewMetric = useMemo( + () => + getPrioritizedViewPermission(metricPermissions, Operation.ViewBasic) === + true, + [metricPermissions] + ); - const saveUpdatedMetricData = (updatedData: Metric) => { - const jsonPatch = compare(omitBy(metricDetails, isUndefined), updatedData); + const metricCacheKey = useMemo( + () => metricQueryKey(metricFqn, METRIC_DEFAULT_FIELDS), + [metricFqn] + ); - return patchMetric(metricId, jsonPatch); - }; + const { + data: metricDetails, + isLoading: metricLoading, + error: metricError, + } = useQuery({ + queryKey: metricCacheKey, + queryFn: metricQueryFn(metricFqn, METRIC_DEFAULT_FIELDS), + enabled: Boolean(metricFqn && canViewMetric && !permissionsLoading), + }); - const handleMetricUpdate = async ( - updatedData: Metric, - key?: keyof Metric - ) => { - try { - const res = await saveUpdatedMetricData(updatedData); + const isError = useMemo( + () => (metricError as AxiosError | undefined)?.response?.status === 404, + [metricError] + ); - if (key === 'unitOfMeasurement') { - setMetricDetails((previous) => ({ - ...previous, - version: res.version, - unitOfMeasurement: res.unitOfMeasurement, - customUnitOfMeasurement: res.customUnitOfMeasurement, - })); - } else { - setMetricDetails((previous) => { - return { - ...previous, - version: res.version, - ...(key ? { [key]: res[key] } : res), - }; - }); - } - } catch (error) { - showErrorToast(error as AxiosError); + useEffect(() => { + const status = (metricError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + metricError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.metric'), + entityName: metricFqn, + }) + ); } - }; + }, [metricError, navigate, metricFqn, t]); + useEffect(() => { + if (!metricDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(metricDetails), + entityType: EntityType.METRIC, + fqn: metricDetails.fullyQualifiedName ?? '', + timestamp: 0, + id: metricDetails.id, + }); + }, [metricDetails]); + + const setMetricDetails = useCallback( + ( + updater: + | Metric + | undefined + | ((prev: Metric | undefined) => Metric | undefined) + ) => { + queryClient.setQueryData(metricCacheKey, updater); + }, + [queryClient, metricCacheKey] + ); + + const refetchMetricDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: metricCacheKey }), + [queryClient, metricCacheKey] + ); + + const { id: metricId, version: currentVersion } = metricDetails ?? {}; + const isFollowing = useMemo( + () => metricDetails?.followers?.some(({ id }) => id === currentUserId), + [metricDetails?.followers, currentUserId] + ); + const entityName = useMemo( + () => getEntityName(metricDetails), + [metricDetails] + ); + + // See DashboardDetailsPage for the rationale on NOT using useCallback here. const fetchResourcePermission = async (entityFqn: string) => { - setLoading(true); + setPermissionsLoading(true); try { const permissions = await getEntityPermissionByFqn( ResourceEntity.METRIC, @@ -121,95 +172,135 @@ const MetricDetailsPage = () => { }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }; - const fetchMetricDetail = async (metricFqn: string) => { - setLoading(true); + const saveUpdatedMetricData = useCallback( + (updatedData: Metric) => { + if (!metricDetails || !metricId) { + return Promise.reject(new Error('Metric not loaded')); + } + const jsonPatch = compare( + omitBy(metricDetails, isUndefined), + updatedData + ); + + return patchMetric(metricId, jsonPatch); + }, + [metricDetails, metricId] + ); + + const handleMetricUpdate = async ( + updatedData: Metric, + key?: keyof Metric + ) => { try { - const res = await getMetricByFqn(metricFqn, { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.FOLLOWERS, - TabSpecificField.TAGS, - TabSpecificField.DOMAINS, - TabSpecificField.DATA_PRODUCTS, - TabSpecificField.VOTES, - TabSpecificField.EXTENSION, - TabSpecificField.RELATED_METRICS, - TabSpecificField.REVIEWERS, - ].join(','), - }); - const { id, fullyQualifiedName } = res; + const res = await saveUpdatedMetricData(updatedData); - setMetricDetails(res); + if (key === 'unitOfMeasurement') { + setMetricDetails((previous) => { + if (!previous) { + return previous; + } - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.METRIC, - fqn: fullyQualifiedName ?? '', - timestamp: 0, - id: id, - }); - } catch (error) { - if ((error as AxiosError).response?.status === 404) { - setIsError(true); - } else if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); + return { + ...previous, + version: res.version, + unitOfMeasurement: res.unitOfMeasurement, + customUnitOfMeasurement: res.customUnitOfMeasurement, + }; + }); } else { - showErrorToast( - error as AxiosError, - t('server.entity-details-fetch-error', { - entityType: t('label.metric'), - entityName: metricFqn, - }) - ); + setMetricDetails((previous) => { + if (!previous) { + return previous; + } + + return { + ...previous, + version: res.version, + ...(key ? { [key]: res[key] } : res), + }; + }); } - } finally { - setLoading(false); + } catch (error) { + showErrorToast(error as AxiosError); } }; - const followMetric = async () => { - try { - const res = await addMetricFollower(metricId, currentUserId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setMetricDetails((prev) => ({ - ...prev, - followers: [...(prev?.followers ?? []), ...newValue], - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(metricDetails), - }) + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Metric | undefined } + >({ + mutationFn: async () => { + if (!metricId) { + return; + } + if (isFollowing) { + await removeMetricFollower(metricId, currentUserId); + } else { + await addMetricFollower(metricId, currentUserId); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: metricCacheKey }); + const previous = queryClient.getQueryData( + metricCacheKey ); - } - }; + queryClient.setQueryData(metricCacheKey, (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter( + ({ id }) => id !== currentUserId + ), + }; + } - const unFollowMetric = async () => { - try { - const res = await removeMetricFollower(metricId, currentUserId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setMetricDetails((prev) => ({ - ...prev, - followers: (prev?.followers ?? []).filter( - (follower) => follower.id !== oldValue[0].id - ), - })); - } catch (error) { + return { + ...prev, + followers: [ + ...currentFollowers, + { id: currentUserId, type: 'user' }, + ] as Metric['followers'], + }; + }); + + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + metricCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(metricDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { entity: entityName }) + : t('server.entity-follow-error', { entity: entityName }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: metricCacheKey }); + }, + }); + + const followMetric = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); + + const unFollowMetric = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const versionHandler = () => { currentVersion && @@ -235,41 +326,28 @@ const MetricDetailsPage = () => { const handleUpdateVote = async (data: QueryVote, id: string) => { try { await updateMetricVote(id, data); - const details = await getMetricByFqn(metricFqn, { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.FOLLOWERS, - TabSpecificField.TAGS, - TabSpecificField.VOTES, - TabSpecificField.REVIEWERS, - ].join(','), - }); - setMetricDetails(details); + await queryClient.invalidateQueries({ queryKey: metricCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } }; - const updateMetricDetails = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as Metric; - - setMetricDetails((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + const updateMetricDetails = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as Metric; + setMetricDetails((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setMetricDetails] + ); useEffect(() => { fetchResourcePermission(metricFqn); }, [metricFqn]); - useEffect(() => { - if (getPrioritizedViewPermission(metricPermissions, Operation.ViewBasic)) { - fetchMetricDetail(metricFqn); - } - }, [metricPermissions, metricFqn]); - - if (isLoading) { + if (permissionsLoading || metricLoading) { return ; } if (isError) { @@ -290,10 +368,13 @@ const MetricDetailsPage = () => { /> ); } + if (!metricDetails) { + return ; + } return ( fetchMetricDetail(metricFqn)} + fetchMetricDetails={refetchMetricDetails} metricDetails={metricDetails} metricPermissions={metricPermissions} onFollowMetric={followMetric} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.test.tsx index 516820170f26..fa5348a79fc0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.test.tsx @@ -11,9 +11,10 @@ * limitations under the License. */ -import { findByTestId, render } from '@testing-library/react'; +import { findByTestId, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { getMlModelByFQN } from '../../rest/mlModelAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import MlModelPageComponent from './MlModelPage.component'; const mockData = { @@ -183,6 +184,12 @@ jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ })), })); +jest.mock('../../hooks/useFqn', () => ({ + useFqn: jest.fn(() => ({ + entityFqn: 'eta_predictions', + })), +})); + jest.mock('../../utils/PermissionsUtils', () => ({ DEFAULT_ENTITY_PERMISSION: { Create: true, @@ -212,9 +219,11 @@ jest.mock('../../utils/PermissionsUtils', () => ({ describe('Test MlModel Entity Page', () => { it('Should render component', async () => { - const { container } = render(, { - wrapper: MemoryRouter, - }); + const { container } = renderWithQueryClient( + + + + ); const mlModelDetailComponent = await findByTestId( container, @@ -226,11 +235,20 @@ describe('Test MlModel Entity Page', () => { it('Should render error component if API fails', async () => { (getMlModelByFQN as jest.Mock).mockImplementationOnce(() => - Promise.reject() + Promise.reject(new Error('failed')) + ); + const { container } = renderWithQueryClient( + + + + ); + + await waitFor( + () => { + expect(getMlModelByFQN).toHaveBeenCalled(); + }, + { timeout: 5000 } ); - const { container } = render(, { - wrapper: MemoryRouter, - }); const errorComponent = await findByTestId(container, 'no-data-placeholder'); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx index 1e1c57d35d0c..dd9f7e1984a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx @@ -11,9 +11,10 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; -import { isEmpty, isNil, isUndefined, omitBy, toString } from 'lodash'; +import { isUndefined, omitBy, toString } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -34,11 +35,14 @@ import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { addFollower, - getMlModelByFQN, patchMlModelDetails, removeFollower, updateMlModelVotes, } from '../../rest/mlModelAPI'; +import { + mlModelQueryFn, + mlModelQueryKey, +} from '../../rest/queries/mlModelQuery'; import { addToRecentViewed, getEntityMissingError, @@ -56,19 +60,117 @@ const MlModelPage = () => { const { t } = useTranslation(); const { currentUser } = useApplicationStore(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { entityFqn: mlModelFqn } = useFqn({ type: EntityType.MLMODEL }); - const [mlModelDetail, setMlModelDetail] = useState({} as Mlmodel); - const [isDetailLoading, setIsDetailLoading] = useState(false); const USERId = currentUser?.id ?? ''; + const [permissionsLoading, setPermissionsLoading] = useState(true); const [mlModelPermissions, setPipelinePermissions] = useState( DEFAULT_ENTITY_PERMISSION ); const { getEntityPermissionByFqn } = usePermissionProvider(); + const viewUsagePermission = useMemo( + () => + getPrioritizedViewPermission( + mlModelPermissions, + PermissionOperation.ViewUsage + ), + [mlModelPermissions] + ); + + const canViewMlModel = useMemo( + () => + getPrioritizedViewPermission( + mlModelPermissions, + PermissionOperation.ViewBasic + ) === true, + [mlModelPermissions] + ); + + const mlModelFields = useMemo(() => { + let fields = defaultFields; + if (viewUsagePermission) { + fields += `,${TabSpecificField.USAGE_SUMMARY}`; + } + + return fields; + }, [viewUsagePermission]); + + const mlModelCacheKey = useMemo( + () => mlModelQueryKey(mlModelFqn, mlModelFields), + [mlModelFqn, mlModelFields] + ); + + const { + data: mlModelDetail, + isLoading: mlModelLoading, + error: mlModelError, + } = useQuery({ + queryKey: mlModelCacheKey, + queryFn: mlModelQueryFn(mlModelFqn, mlModelFields), + enabled: Boolean(mlModelFqn && canViewMlModel && !permissionsLoading), + }); + + useEffect(() => { + if (!mlModelError) { + return; + } + const status = (mlModelError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + + return; + } + showErrorToast(mlModelError as AxiosError); + }, [mlModelError, navigate]); + + useEffect(() => { + if (!mlModelDetail) { + return; + } + addToRecentViewed({ + displayName: getEntityName(mlModelDetail), + entityType: EntityType.MLMODEL, + fqn: mlModelDetail.fullyQualifiedName ?? '', + serviceType: mlModelDetail.serviceType, + timestamp: 0, + id: mlModelDetail.id, + }); + }, [mlModelDetail]); + + const setMlModelDetail = useCallback( + ( + updater: + | Mlmodel + | undefined + | ((prev: Mlmodel | undefined) => Mlmodel | undefined) + ) => { + queryClient.setQueryData(mlModelCacheKey, updater); + }, + [queryClient, mlModelCacheKey] + ); + + const refetchMlModel = useCallback( + () => queryClient.invalidateQueries({ queryKey: mlModelCacheKey }), + [queryClient, mlModelCacheKey] + ); + + const { mlModelId, followers } = useMemo(() => { + return { + mlModelId: mlModelDetail?.id, + followers: mlModelDetail?.followers ?? [], + }; + }, [mlModelDetail]); + + const isFollowing = useMemo( + () => followers.some(({ id }) => id === USERId), + [followers, USERId] + ); + const fetchResourcePermission = async (entityFqn: string) => { - setIsDetailLoading(true); + setPermissionsLoading(true); try { const entityPermission = await getEntityPermissionByFqn( ResourceEntity.ML_MODEL, @@ -82,106 +184,99 @@ const MlModelPage = () => { }) ); } finally { - setIsDetailLoading(false); - } - }; - - const viewUsagePermission = useMemo( - () => - getPrioritizedViewPermission( - mlModelPermissions, - PermissionOperation.ViewUsage - ), - [mlModelPermissions] - ); - - const fetchMlModelDetails = async (name: string) => { - setIsDetailLoading(true); - try { - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; - } - const res = await getMlModelByFQN(name, { fields }); - setMlModelDetail(res); - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.MLMODEL, - fqn: res.fullyQualifiedName ?? '', - serviceType: res.serviceType, - timestamp: 0, - id: res.id, - }); - } catch (error) { - showErrorToast(error as AxiosError); - if ((error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } - } finally { - setIsDetailLoading(false); + setPermissionsLoading(false); } }; - useEffect(() => { - if ( - getPrioritizedViewPermission( - mlModelPermissions, - PermissionOperation.ViewBasic - ) - ) { - fetchMlModelDetails(mlModelFqn); - } - }, [mlModelPermissions, mlModelFqn]); - const saveUpdatedMlModelData = useCallback( (updatedData: Mlmodel) => { + if (!mlModelDetail || !mlModelId) { + return Promise.reject(new Error('MlModel not loaded')); + } const jsonPatch = compare( omitBy(mlModelDetail, isUndefined), updatedData ); - return patchMlModelDetails(mlModelDetail.id, jsonPatch); + return patchMlModelDetails(mlModelId, jsonPatch); }, - [mlModelDetail] + [mlModelDetail, mlModelId] ); - const followMlModel = async () => { - try { - const res = await addFollower(mlModelDetail.id, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setMlModelDetail((preVDetail) => ({ - ...preVDetail, - followers: [...(mlModelDetail.followers || []), ...newValue], - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(mlModelDetail), - }) + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Mlmodel | undefined } + >({ + mutationFn: async () => { + if (!mlModelId) { + return; + } + if (isFollowing) { + await removeFollower(mlModelId, USERId); + } else { + await addFollower(mlModelId, USERId); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: mlModelCacheKey }); + const previous = queryClient.getQueryData( + mlModelCacheKey ); - } - }; + queryClient.setQueryData(mlModelCacheKey, (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } - const unFollowMlModel = async () => { - try { - const res = await removeFollower(mlModelDetail.id, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setMlModelDetail((preVDetail) => ({ - ...preVDetail, - followers: (mlModelDetail.followers ?? []).filter( - (follower) => follower.id !== oldValue[0].id - ), - })); - } catch (error) { + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as Mlmodel['followers'], + }; + }); + + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + mlModelCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(mlModelDetail), - }) + isFollowing + ? t('server.entity-unfollow-error', { + entity: getEntityName(mlModelDetail), + }) + : t('server.entity-follow-error', { + entity: getEntityName(mlModelDetail), + }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: mlModelCacheKey }); + }, + }); + + const followMlModel = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); + + const unFollowMlModel = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const settingsUpdateHandler = async ( updatedMlModel: Mlmodel @@ -189,13 +284,19 @@ const MlModelPage = () => { try { const { displayName, owners, tags, version } = await saveUpdatedMlModelData(updatedMlModel); - setMlModelDetail((preVDetail) => ({ - ...preVDetail, - displayName, - owners, - tags, - version, - })); + setMlModelDetail((preVDetail) => { + if (!preVDetail) { + return preVDetail; + } + + return { + ...preVDetail, + displayName, + owners, + tags, + version, + }; + }); } catch (error) { showErrorToast( error as AxiosError, @@ -211,7 +312,7 @@ const MlModelPage = () => { getVersionPath( EntityType.MLMODEL, mlModelFqn, - toString(mlModelDetail.version) + toString(mlModelDetail?.version) ) ); }; @@ -233,12 +334,7 @@ const MlModelPage = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updateMlModelVotes(id, data); - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; - } - const details = await getMlModelByFQN(mlModelFqn, { fields }); - setMlModelDetail(details); + await queryClient.invalidateQueries({ queryKey: mlModelCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } @@ -247,23 +343,28 @@ const MlModelPage = () => { const updateMlModelDetailsState = useCallback( (data: DataAssetWithDomains) => { const updatedData = data as Mlmodel; - - setMlModelDetail((data) => ({ - ...(updatedData ?? data), + setMlModelDetail((prev) => ({ + ...(updatedData ?? prev), version: updatedData.version, })); }, - [] + [setMlModelDetail] ); const handleMlModelUpdate = useCallback( async (data: Mlmodel) => { try { const response = await saveUpdatedMlModelData(data); - setMlModelDetail((prev) => ({ - ...prev, - ...response, - })); + setMlModelDetail((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + ...response, + }; + }); } catch (error) { showErrorToast( error as AxiosError, @@ -273,8 +374,9 @@ const MlModelPage = () => { ); } }, - [saveUpdatedMlModelData] + [saveUpdatedMlModelData, setMlModelDetail, mlModelDetail, t] ); + const onMlModelUpdateCertification = async ( updatedMlModel: Mlmodel, key?: keyof Mlmodel @@ -282,6 +384,10 @@ const MlModelPage = () => { try { const response = await saveUpdatedMlModelData(updatedMlModel); setMlModelDetail((previous) => { + if (!previous) { + return previous; + } + return { ...previous, version: response.version, @@ -297,11 +403,11 @@ const MlModelPage = () => { fetchResourcePermission(mlModelFqn); }, [mlModelFqn]); - if (isDetailLoading) { + if (permissionsLoading || mlModelLoading) { return ; } - if (isNil(mlModelDetail) || isEmpty(mlModelDetail)) { + if (mlModelError) { return ( {getEntityMissingError('mlModel', mlModelFqn)} @@ -321,9 +427,13 @@ const MlModelPage = () => { ); } + if (!mlModelDetail) { + return ; + } + return ( fetchMlModelDetails(mlModelFqn)} + fetchMlModel={refetchMlModel} followMlModelHandler={followMlModel} handleToggleDelete={handleToggleDelete} mlModelDetail={mlModelDetail} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx index cab5295a6956..79d8f762a982 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MyDataPage/MyDataPage.component.tsx @@ -17,6 +17,7 @@ import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import RGL, { ReactGridLayoutProps, WidthProvider } from 'react-grid-layout'; import { useTranslation } from 'react-i18next'; +import DeferredWidget from '../../components/common/DeferredWidget/DeferredWidget.component'; import Loader from '../../components/common/Loader/Loader'; import { AdvanceSearchProvider } from '../../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component'; import CustomiseLandingPageHeader from '../../components/MyData/CustomizableComponents/CustomiseLandingPageHeader/CustomiseLandingPageHeader'; @@ -159,14 +160,40 @@ const MyDataPage = () => { const widgets = useMemo( () => - layout.map((widget) => ( -
- {getWidgetFromKey({ - widgetConfig: widget, - currentLayout: layout, - })} -
- )), + layout.map((widget) => { + const widgetNode = getWidgetFromKey({ + widgetConfig: widget, + currentLayout: layout, + }); + + // P1.3: defer below-fold widgets. The landing-page grid spans three rows on a typical + // viewport; rows at y=0 and y=1 are reliably visible on first paint, row y=2 is + // typically below the fold on common desktop resolutions. Wrapping only y>=2 widgets + // saves their data-fetch effects on initial load while keeping above-fold widgets + // eager (no wasted IO callback round-trip). + // + // {@link DeferredWidget} reserves the widget's pixel height so the page layout + // doesn't shift when the real content mounts, exposes a {@code data-testid} so + // Playwright can locate the slot before the child tree mounts, and falls back to + // immediate mount if {@code IntersectionObserver} isn't available (Jest, SSR). + const isBelowFold = widget.y >= 2; + const reservedHeight = + widget.h * customizePageClassBase.landingPageRowHeight; + + return ( +
+ {isBelowFold ? ( + + {widgetNode} + + ) : ( + widgetNode + )} +
+ ); + }), [layout, isAnnouncementLoading, announcements] ); @@ -273,9 +300,11 @@ const MyDataPage = () => {
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx index 7cd312470555..a95443cc69ca 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { compare, Operation } from 'fast-json-patch'; import { isUndefined, omitBy } from 'lodash'; @@ -35,11 +36,14 @@ import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useFqn } from '../../hooks/useFqn'; import { addFollower, - getPipelineByFqn, patchPipelineDetails, removeFollower, updatePipelinesVotes, } from '../../rest/pipelineAPI'; +import { + pipelineQueryFn, + pipelineQueryKey, +} from '../../rest/queries/pipelineQuery'; import { addToRecentViewed, getEntityMissingError, @@ -58,18 +62,13 @@ const PipelineDetailsPage = () => { const { currentUser } = useApplicationStore(); const USERId = currentUser?.id ?? ''; const navigate = useNavigate(); + const queryClient = useQueryClient(); const { entityFqn: decodedPipelineFQN } = useFqn({ type: EntityType.PIPELINE, }); - const [pipelineDetails, setPipelineDetails] = useState( - {} as Pipeline - ); - - const [isLoading, setLoading] = useState(true); - - const [isError, setIsError] = useState(false); + const [permissionsLoading, setPermissionsLoading] = useState(true); const [paging] = useState({} as Paging); const [pipelinePermissions, setPipelinePermissions] = useState( @@ -78,10 +77,120 @@ const PipelineDetailsPage = () => { const { getEntityPermissionByFqn } = usePermissionProvider(); - const { followers = [] } = pipelineDetails; + const viewUsagePermission = useMemo( + () => + getPrioritizedViewPermission( + pipelinePermissions, + PermissionOperation.ViewUsage + ), + [pipelinePermissions] + ); + const canViewPipeline = useMemo( + () => + getPrioritizedViewPermission( + pipelinePermissions, + PermissionOperation.ViewBasic + ) === true, + [pipelinePermissions] + ); + + const pipelineFields = useMemo(() => { + let fields = defaultFields; + if (viewUsagePermission) { + fields += `,${TabSpecificField.USAGE_SUMMARY}`; + } + + return fields; + }, [viewUsagePermission]); + + const pipelineCacheKey = useMemo( + () => pipelineQueryKey(decodedPipelineFQN, pipelineFields), + [decodedPipelineFQN, pipelineFields] + ); + + const { + data: pipelineDetails, + isLoading: pipelineLoading, + error: pipelineError, + } = useQuery({ + queryKey: pipelineCacheKey, + queryFn: pipelineQueryFn(decodedPipelineFQN, pipelineFields), + enabled: Boolean( + decodedPipelineFQN && canViewPipeline && !permissionsLoading + ), + }); + + const isError = useMemo( + () => (pipelineError as AxiosError | undefined)?.response?.status === 404, + [pipelineError] + ); + + useEffect(() => { + const status = (pipelineError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + pipelineError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.pipeline'), + entityName: decodedPipelineFQN, + }) + ); + } + }, [pipelineError, navigate, decodedPipelineFQN, t]); + + useEffect(() => { + if (!pipelineDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(pipelineDetails), + entityType: EntityType.PIPELINE, + fqn: pipelineDetails.fullyQualifiedName ?? '', + serviceType: pipelineDetails.serviceType, + timestamp: 0, + id: pipelineDetails.id, + }); + }, [pipelineDetails]); + + const setPipelineDetails = useCallback( + ( + updater: + | Pipeline + | undefined + | ((prev: Pipeline | undefined) => Pipeline | undefined) + ) => { + queryClient.setQueryData(pipelineCacheKey, updater); + }, + [queryClient, pipelineCacheKey] + ); + + const refetchPipelineDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: pipelineCacheKey }), + [queryClient, pipelineCacheKey] + ); + + const { pipelineId, currentVersion, followers } = useMemo(() => { + return { + pipelineId: pipelineDetails?.id, + currentVersion: + pipelineDetails?.version !== undefined + ? pipelineDetails.version + '' + : '', + followers: pipelineDetails?.followers ?? [], + }; + }, [pipelineDetails]); + + const isFollowing = useMemo( + () => followers.some(({ id }) => id === USERId), + [followers, USERId] + ); + + // See DashboardDetailsPage for the rationale on NOT using useCallback here. const fetchResourcePermission = async (entityFqn: string) => { - setLoading(true); + setPermissionsLoading(true); try { const entityPermission = await getEntityPermissionByFqn( ResourceEntity.PIPELINE, @@ -90,24 +199,18 @@ const PipelineDetailsPage = () => { setPipelinePermissions(entityPermission); } catch { showErrorToast( - t('server.fetch-entity-permissions-error', { - entity: entityFqn, - }) + t('server.fetch-entity-permissions-error', { entity: entityFqn }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }; - const { pipelineId, currentVersion } = useMemo(() => { - return { - pipelineId: pipelineDetails.id, - currentVersion: pipelineDetails.version + '', - }; - }, [pipelineDetails]); - const saveUpdatedPipelineData = useCallback( (updatedData: Pipeline) => { + if (!pipelineDetails || !pipelineId) { + return Promise.reject(new Error('Pipeline not loaded')); + } const jsonPatch = compare( omitBy(pipelineDetails, isUndefined), updatedData @@ -115,99 +218,86 @@ const PipelineDetailsPage = () => { return patchPipelineDetails(pipelineId, jsonPatch); }, - [pipelineDetails] - ); - - const viewUsagePermission = useMemo( - () => - getPrioritizedViewPermission( - pipelinePermissions, - PermissionOperation.ViewUsage - ), - [pipelinePermissions] + [pipelineDetails, pipelineId] ); - const fetchPipelineDetail = async (pipelineFQN: string) => { - setLoading(true); - - try { - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Pipeline | undefined } + >({ + mutationFn: async () => { + if (!pipelineId) { + return; } - const res = await getPipelineByFqn(pipelineFQN, { - fields, - }); - const { id, fullyQualifiedName, serviceType } = res; - - setPipelineDetails(res); - - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.PIPELINE, - fqn: fullyQualifiedName ?? '', - serviceType: serviceType, - timestamp: 0, - id: id, - }); - } catch (error) { - if ((error as AxiosError).response?.status === 404) { - setIsError(true); - } else if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); + if (isFollowing) { + await removeFollower(pipelineId, USERId); } else { - showErrorToast( - error as AxiosError, - t('server.entity-details-fetch-error', { - entityType: t('label.pipeline'), - entityName: decodedPipelineFQN, - }) - ); + await addFollower(pipelineId, USERId); } - } finally { - setLoading(false); - } - }; + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: pipelineCacheKey }); + const previous = queryClient.getQueryData( + pipelineCacheKey + ); + queryClient.setQueryData( + pipelineCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } + + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as Pipeline['followers'], + }; + } + ); - const followPipeline = useCallback(async () => { - try { - const res = await addFollower(pipelineId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - const newFollowers = [...(followers ?? []), ...newValue]; - setPipelineDetails((prev) => { - return { ...prev, followers: newFollowers }; - }); - } catch (error) { + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + pipelineCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(pipelineDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { + entity: getEntityName(pipelineDetails), + }) + : t('server.entity-follow-error', { + entity: getEntityName(pipelineDetails), + }) ); - } - }, [followers, USERId]); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: pipelineCacheKey }); + }, + }); + + const followPipeline = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const unFollowPipeline = useCallback(async () => { - try { - const res = await removeFollower(pipelineId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setPipelineDetails((prev) => ({ - ...prev, - followers: followers.filter( - (follower) => follower.id !== oldValue[0].id - ), - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(pipelineDetails), - }) - ); - } - }, [followers, USERId]); + await followMutation.mutateAsync(); + }, [followMutation]); const descriptionUpdateHandler = async (updatedPipeline: Pipeline) => { try { @@ -225,6 +315,10 @@ const PipelineDetailsPage = () => { try { const response = await saveUpdatedPipelineData(updatedPipeline); setPipelineDetails((previous) => { + if (!previous) { + return previous; + } + return { ...previous, version: response.version, @@ -251,6 +345,9 @@ const PipelineDetailsPage = () => { }; const onTaskUpdate = async (jsonPatch: Array) => { + if (!pipelineId) { + return; + } try { const response = await patchPipelineDetails(pipelineId, jsonPatch); setPipelineDetails(response); @@ -261,15 +358,14 @@ const PipelineDetailsPage = () => { const versionHandler = () => { navigate( - getVersionPath( - EntityType.PIPELINE, - decodedPipelineFQN, - currentVersion as string - ) + getVersionPath(EntityType.PIPELINE, decodedPipelineFQN, currentVersion) ); }; const handleExtensionUpdate = async (updatedPipeline: Pipeline) => { + if (!pipelineDetails) { + return; + } try { const data = await saveUpdatedPipelineData({ ...pipelineDetails, @@ -303,14 +399,7 @@ const PipelineDetailsPage = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updatePipelinesVotes(id, data); - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; - } - const details = await getPipelineByFqn(decodedPipelineFQN, { - fields, - }); - setPipelineDetails(details); + await queryClient.invalidateQueries({ queryKey: pipelineCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } @@ -319,31 +408,19 @@ const PipelineDetailsPage = () => { const updatePipelineDetailsState = useCallback( (data: DataAssetWithDomains) => { const updatedData = data as Pipeline; - - setPipelineDetails((data) => ({ - ...(updatedData ?? data), + setPipelineDetails((prev) => ({ + ...(updatedData ?? prev), version: updatedData.version, })); }, - [] + [setPipelineDetails] ); - useEffect(() => { - if ( - getPrioritizedViewPermission( - pipelinePermissions, - PermissionOperation.ViewBasic - ) - ) { - fetchPipelineDetail(decodedPipelineFQN); - } - }, [pipelinePermissions, decodedPipelineFQN]); - useEffect(() => { fetchResourcePermission(decodedPipelineFQN); }, [decodedPipelineFQN]); - if (isLoading) { + if (permissionsLoading || pipelineLoading) { return ; } @@ -367,10 +444,14 @@ const PipelineDetailsPage = () => { ); } + if (!pipelineDetails) { + return ; + } + return ( fetchPipelineDetail(decodedPipelineFQN)} + fetchPipeline={refetchPipelineDetails} followPipelineHandler={followPipeline} handleToggleDelete={handleToggleDelete} paging={paging} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.test.tsx index 0db5fa346988..b86358a8d6ba 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.test.tsx @@ -11,13 +11,13 @@ * limitations under the License. */ -import { act, findByText, render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import PipelineDetailsPage from './PipelineDetailsPage.component'; jest.mock('react-router-dom', () => ({ useParams: jest.fn().mockReturnValue({ - pipelineFQN: 'sample_airflow.snowflake_etl', + fqn: 'sample_airflow.snowflake_etl', tab: 'details', }), useNavigate: jest.fn().mockImplementation(() => jest.fn()), @@ -52,7 +52,7 @@ jest.mock( jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ usePermissionProvider: jest.fn().mockImplementation(() => ({ permissions: {}, - getEntityPermission: jest.fn().mockResolvedValue({ + getEntityPermissionByFqn: jest.fn().mockResolvedValue({ Create: true, Delete: true, EditAll: true, @@ -106,17 +106,10 @@ jest.mock('../../utils/PermissionsUtils', () => ({ describe('Test PipelineDetailsPage component', () => { it('PipelineDetailsPage component should render properly', async () => { - const { container } = render(, { - wrapper: MemoryRouter, - }); + renderWithQueryClient(); - await act(async () => { - const PipelineDetails = await findByText( - container, - /PipelineDetails.component/i - ); - - expect(PipelineDetails).toBeInTheDocument(); - }); + await waitFor(() => + expect(screen.getByText(/PipelineDetails.component/i)).toBeInTheDocument() + ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.test.tsx index 5ae487ba0a6b..d1823f21c8e2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.test.tsx @@ -11,14 +11,22 @@ * limitations under the License. */ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { getSearchIndexDetailsByFQN } from '../../rest/SearchIndexAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import SearchIndexDetailsPage from './SearchIndexDetailsPage'; +const renderPage = () => + renderWithQueryClient( + + + + ); + const mockEntityPermissionByFqn = jest .fn() .mockImplementation(() => DEFAULT_ENTITY_PERMISSION); @@ -162,11 +170,7 @@ jest.mock('../../hooks/useFqn', () => ({ describe('SearchIndexDetailsPage component', () => { it('SearchIndexDetailsPage should fetch permissions', async () => { - render( - - - - ); + renderPage(); await waitFor(() => { expect(mockEntityPermissionByFqn).toHaveBeenCalledWith( @@ -180,11 +184,7 @@ describe('SearchIndexDetailsPage component', () => { // Reset mocks to ensure clean state jest.clearAllMocks(); - render( - - - - ); + renderPage(); await waitFor(() => { // Should try to resolve FQN first, so it MIGHT be called to resolve @@ -212,11 +212,7 @@ describe('SearchIndexDetailsPage component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); await waitFor( @@ -241,11 +237,7 @@ describe('SearchIndexDetailsPage component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); await waitFor( @@ -275,11 +267,7 @@ describe('SearchIndexDetailsPage component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); await waitFor( @@ -320,11 +308,7 @@ describe('SearchIndexDetailsPage component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); await waitFor( diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx index d1da2ef2a6b8..6a354680a4d1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Col, Row, Tabs } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; @@ -46,15 +47,23 @@ import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; +import { + searchIndexQueryFn, + searchIndexQueryKey, +} from '../../rest/queries/searchIndexQuery'; import { addFollower, - getSearchIndexDetailsByFQN, patchSearchIndexDetails, removeFollower, restoreSearchIndex, updateSearchIndexVotes, } from '../../rest/SearchIndexAPI'; -import { addToRecentViewed, getFeedCounts } from '../../utils/CommonUtils'; +import { + addToRecentViewed, + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -85,10 +94,10 @@ function SearchIndexDetailsPage() { const { t } = useTranslation(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { currentUser } = useApplicationStore(); const USERId = currentUser?.id ?? ''; - const [loading, setLoading] = useState(true); - const [searchIndexDetails, setSearchIndexDetails] = useState(); + const [permissionsLoading, setPermissionsLoading] = useState(true); const [feedCount, setFeedCount] = useState( FEED_COUNT_INITIAL_DATA ); @@ -103,29 +112,56 @@ function SearchIndexDetailsPage() { [searchIndexPermissions] ); - const fetchSearchIndexDetails = async () => { - setLoading(true); - try { - const fields = defaultFields; - const details = await getSearchIndexDetailsByFQN(decodedSearchIndexFQN, { - fields, - }); + const searchIndexCacheKey = useMemo( + () => searchIndexQueryKey(decodedSearchIndexFQN, defaultFields), + [decodedSearchIndexFQN] + ); - setSearchIndexDetails(details); - addToRecentViewed({ - displayName: getEntityName(details), - entityType: EntityType.SEARCH_INDEX, - fqn: details.fullyQualifiedName ?? '', - serviceType: details.serviceType, - timestamp: 0, - id: details.id, - }); - } catch { - // Error here - } finally { - setLoading(false); + const { + data: searchIndexDetails, + isLoading: searchIndexLoading, + error: searchIndexError, + } = useQuery({ + queryKey: searchIndexCacheKey, + queryFn: searchIndexQueryFn(decodedSearchIndexFQN, defaultFields), + enabled: Boolean( + decodedSearchIndexFQN && viewPermission && !permissionsLoading + ), + }); + + useEffect(() => { + if (!searchIndexDetails) { + return; } - }; + addToRecentViewed({ + displayName: getEntityName(searchIndexDetails), + entityType: EntityType.SEARCH_INDEX, + fqn: searchIndexDetails.fullyQualifiedName ?? '', + serviceType: searchIndexDetails.serviceType, + timestamp: 0, + id: searchIndexDetails.id, + }); + }, [searchIndexDetails]); + + const setSearchIndexDetails = useCallback( + ( + updater: + | SearchIndex + | undefined + | ((prev: SearchIndex | undefined) => SearchIndex | undefined) + ) => { + queryClient.setQueryData( + searchIndexCacheKey, + updater + ); + }, + [queryClient, searchIndexCacheKey] + ); + + const refetchSearchIndexDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: searchIndexCacheKey }), + [queryClient, searchIndexCacheKey] + ); const { searchIndexTags, @@ -211,6 +247,7 @@ function SearchIndexDetailsPage() { const fetchResourcePermission = useCallback( async (entityFQN: string) => { + setPermissionsLoading(true); try { const searchIndexPermission = await getEntityPermissionByFqn( ResourceEntity.SEARCH_INDEX, @@ -219,7 +256,7 @@ function SearchIndexDetailsPage() { setSearchIndexPermissions(searchIndexPermission); } finally { - setLoading(false); + setPermissionsLoading(false); } }, [getEntityPermissionByFqn] @@ -236,6 +273,22 @@ function SearchIndexDetailsPage() { handleFeedCount ); + const fetchTaskCounts = useCallback(() => { + if (decodedSearchIndexFQN) { + fetchEntityTaskCountsInto(decodedSearchIndexFQN, setFeedCount); + } + }, [decodedSearchIndexFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedSearchIndexFQN) { + fetchEntityActivityCountInto( + EntityType.SEARCH_INDEX, + decodedSearchIndexFQN, + setFeedCount + ); + } + }, [decodedSearchIndexFQN]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { navigate( @@ -344,7 +397,7 @@ function SearchIndexDetailsPage() { feedCount, activeTab, getEntityFeedCount, - fetchSearchIndexDetails, + fetchSearchIndexDetails: refetchSearchIndexDetails, handleFeedCount, viewSampleDataPermission, deleted: deleted ?? false, @@ -379,6 +432,7 @@ function SearchIndexDetailsPage() { editTagsPermission, editGlossaryTermsPermission, editDescriptionPermission, + refetchSearchIndexDetails, ]); const onTierUpdate = useCallback( @@ -432,76 +486,94 @@ function SearchIndexDetailsPage() { } }; - const followSearchIndex = useCallback(async () => { - try { - const res = await addFollower(searchIndexId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - const newFollowers = [...(followers ?? []), ...newValue]; - setSearchIndexDetails((prev) => { - if (!prev) { - return prev; - } + const isFollowing = useMemo( + () => followers?.some(({ id }) => id === USERId), + [followers, USERId] + ); - return { ...prev, followers: newFollowers }; - }); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(searchIndexDetails), - }) + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: SearchIndex | undefined } + >({ + mutationFn: async () => { + if (!searchIndexId) { + return; + } + if (isFollowing) { + await removeFollower(searchIndexId, USERId); + } else { + await addFollower(searchIndexId, USERId); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: searchIndexCacheKey }); + const previous = queryClient.getQueryData( + searchIndexCacheKey ); - } - }, [USERId, searchIndexId]); - - const unFollowSearchIndex = useCallback(async () => { - try { - const res = await removeFollower(searchIndexId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setSearchIndexDetails((pre) => { - if (!pre) { - return pre; + queryClient.setQueryData( + searchIndexCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } + + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as SearchIndex['followers'], + }; } + ); - return { - ...pre, - followers: pre.followers?.filter( - (follower) => follower.id !== oldValue[0].id - ), - }; - }); - } catch (error) { + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + searchIndexCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(searchIndexDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { + entity: getEntityName(searchIndexDetails), + }) + : t('server.entity-follow-error', { + entity: getEntityName(searchIndexDetails), + }) ); - } - }, [USERId, searchIndexId]); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: searchIndexCacheKey }); + }, + }); + + const handleFollowSearchIndex = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const onUpdateVote = async (data: QueryVote, id: string) => { try { await updateSearchIndexVotes(id, data); - const details = await getSearchIndexDetailsByFQN(decodedSearchIndexFQN, { - fields: defaultFields, - }); - setSearchIndexDetails(details); + await queryClient.invalidateQueries({ queryKey: searchIndexCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } }; - const { isFollowing } = useMemo(() => { - return { - isFollowing: followers?.some(({ id }) => id === USERId), - }; - }, [followers, USERId]); - - const handleFollowSearchIndex = useCallback(async () => { - isFollowing ? await unFollowSearchIndex() : await followSearchIndex(); - }, [isFollowing, unFollowSearchIndex, followSearchIndex]); - const versionHandler = useCallback(() => { version && navigate( @@ -518,14 +590,17 @@ function SearchIndexDetailsPage() { [] ); - const afterDomainUpdateAction = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as SearchIndex; + const afterDomainUpdateAction = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as SearchIndex; - setSearchIndexDetails((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + setSearchIndexDetails((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setSearchIndexDetails] + ); useEffect(() => { if (decodedSearchIndexFQN) { @@ -535,8 +610,8 @@ function SearchIndexDetailsPage() { useEffect(() => { if (viewPermission) { - fetchSearchIndexDetails(); - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); } }, [decodedSearchIndexFQN, viewPermission]); @@ -564,7 +639,7 @@ function SearchIndexDetailsPage() { () => checkIfExpandViewSupported(tabs[0], activeTab, PageType.SearchIndex), [tabs[0], activeTab] ); - if (isLoading || loading) { + if (isLoading || permissionsLoading || searchIndexLoading) { return ; } @@ -580,7 +655,7 @@ function SearchIndexDetailsPage() { ); } - if (!searchIndexDetails) { + if (searchIndexError || !searchIndexDetails) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.test.tsx index 4979f6a4def0..d9874a828b70 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.test.tsx @@ -11,16 +11,24 @@ * limitations under the License. */ -import { act, render, screen } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { GenericTab } from '../../components/Customization/GenericTab/GenericTab'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { getStoredProceduresByFqn } from '../../rest/storedProceduresAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import { STORED_PROCEDURE_DEFAULT_FIELDS } from '../../utils/StoredProceduresUtils'; import StoredProcedurePage from './StoredProcedurePage'; +const renderPage = () => + renderWithQueryClient( + + + + ); + const mockEntityPermissionByFqn = jest .fn() .mockImplementation(() => DEFAULT_ENTITY_PERMISSION); @@ -45,6 +53,9 @@ jest.mock('../../rest/storedProceduresAPI', () => ({ })); jest.mock('../../utils/CommonUtils', () => ({ + addToRecentViewed: jest.fn(), + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), sortTagsCaseInsensitive: jest.fn(), })); @@ -163,11 +174,7 @@ jest.mock('../../hooks/useEntityRules', () => ({ describe('StoredProcedure component', () => { it('StoredProcedurePage should fetch permissions', () => { - render( - - - - ); + renderPage(); expect(mockEntityPermissionByFqn).toHaveBeenCalledWith( 'storedProcedure', @@ -176,11 +183,7 @@ describe('StoredProcedure component', () => { }); it('StoredProcedurePage should not fetch details if permission is there', () => { - render( - - - - ); + renderPage(); expect(getStoredProceduresByFqn).not.toHaveBeenCalled(); }); @@ -193,11 +196,7 @@ describe('StoredProcedure component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); expect(getStoredProceduresByFqn).toHaveBeenCalledWith('fqn', { @@ -216,11 +215,7 @@ describe('StoredProcedure component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); expect(getStoredProceduresByFqn).toHaveBeenCalledWith('fqn', { @@ -237,11 +232,7 @@ describe('StoredProcedure component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); expect(await screen.findByText('testErrorPlaceHolder')).toBeInTheDocument(); @@ -255,11 +246,7 @@ describe('StoredProcedure component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); expect(getStoredProceduresByFqn).toHaveBeenCalledWith('fqn', { @@ -286,11 +273,7 @@ describe('StoredProcedure component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); expect(getStoredProceduresByFqn).toHaveBeenCalledWith('fqn', { @@ -319,18 +302,16 @@ describe('StoredProcedure component', () => { })); await act(async () => { - render( - - - - ); + renderPage(); }); - expect(PageLayoutV1).toHaveBeenCalledWith( - expect.objectContaining({ - pageTitle: 'test-stored-procedure', - }), - expect.anything() - ); + await waitFor(() => { + expect(PageLayoutV1).toHaveBeenCalledWith( + expect.objectContaining({ + pageTitle: 'test-stored-procedure', + }), + expect.anything() + ); + }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx index 1d3bbaed362a..aaaf14e7d90a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Col, Row, Tabs } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; @@ -43,21 +44,28 @@ import { } from '../../generated/entity/data/storedProcedure'; import { Operation } from '../../generated/entity/policies/policy'; import { PageType } from '../../generated/system/ui/page'; -import { Include } from '../../generated/type/include'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; +import { + storedProcedureQueryFn, + storedProcedureQueryKey, +} from '../../rest/queries/storedProcedureQuery'; import { addStoredProceduresFollower, - getStoredProceduresByFqn, patchStoredProceduresDetails, removeStoredProceduresFollower, restoreStoredProcedures, updateStoredProcedureVotes, } from '../../rest/storedProceduresAPI'; -import { addToRecentViewed, getFeedCounts } from '../../utils/CommonUtils'; +import { + addToRecentViewed, + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, @@ -83,6 +91,7 @@ const StoredProcedurePage = () => { const { currentUser } = useApplicationStore(); const USER_ID = currentUser?.id ?? ''; const navigate = useNavigate(); + const queryClient = useQueryClient(); const { tab: activeTab = EntityTabs.CODE } = useRequiredParams<{ tab: EntityTabs; }>(); @@ -91,8 +100,7 @@ const StoredProcedurePage = () => { type: EntityType.STORED_PROCEDURE, }); const { getEntityPermissionByFqn } = usePermissionProvider(); - const [isLoading, setIsLoading] = useState(true); - const [storedProcedure, setStoredProcedure] = useState(); + const [permissionsLoading, setPermissionsLoading] = useState(true); const [storedProcedurePermissions, setStoredProcedurePermissions] = useState(DEFAULT_ENTITY_PERMISSION); const [isTabExpanded, setIsTabExpanded] = useState(false); @@ -103,6 +111,101 @@ const StoredProcedurePage = () => { FEED_COUNT_INITIAL_DATA ); + const { + editCustomAttributePermission, + editLineagePermission, + viewAllPermission, + viewBasicPermission, + viewCustomPropertiesPermission, + } = useMemo( + () => ({ + editCustomAttributePermission: + storedProcedurePermissions.EditAll || + storedProcedurePermissions.EditCustomFields, + editLineagePermission: + storedProcedurePermissions.EditAll || + storedProcedurePermissions.EditLineage, + viewAllPermission: storedProcedurePermissions.ViewAll, + viewBasicPermission: + storedProcedurePermissions.ViewAll || + storedProcedurePermissions.ViewBasic, + viewCustomPropertiesPermission: getPrioritizedViewPermission( + storedProcedurePermissions, + Operation.ViewCustomFields + ), + }), + [storedProcedurePermissions] + ); + + const storedProcedureCacheKey = useMemo( + () => + storedProcedureQueryKey( + decodedStoredProcedureFQN, + STORED_PROCEDURE_DEFAULT_FIELDS + ), + [decodedStoredProcedureFQN] + ); + + const { + data: storedProcedure, + isLoading: storedProcedureLoading, + error: storedProcedureError, + } = useQuery({ + queryKey: storedProcedureCacheKey, + queryFn: storedProcedureQueryFn( + decodedStoredProcedureFQN, + STORED_PROCEDURE_DEFAULT_FIELDS + ), + enabled: Boolean( + decodedStoredProcedureFQN && viewBasicPermission && !permissionsLoading + ), + }); + + useEffect(() => { + if (!storedProcedureError) { + return; + } + const status = (storedProcedureError as AxiosError | undefined)?.response + ?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } + }, [storedProcedureError, navigate]); + + useEffect(() => { + if (!storedProcedure) { + return; + } + addToRecentViewed({ + displayName: getEntityName(storedProcedure), + entityType: EntityType.STORED_PROCEDURE, + fqn: storedProcedure.fullyQualifiedName ?? '', + serviceType: storedProcedure.serviceType, + timestamp: 0, + id: storedProcedure.id ?? '', + }); + }, [storedProcedure]); + + const setStoredProcedure = useCallback( + ( + updater: + | StoredProcedure + | undefined + | ((prev: StoredProcedure | undefined) => StoredProcedure | undefined) + ) => { + queryClient.setQueryData( + storedProcedureCacheKey, + updater + ); + }, + [queryClient, storedProcedureCacheKey] + ); + + const refetchStoredProcedureDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: storedProcedureCacheKey }), + [queryClient, storedProcedureCacheKey] + ); + const { id: storedProcedureId = '', followers, @@ -133,6 +236,7 @@ const StoredProcedurePage = () => { }, [followers, USER_ID]); const fetchResourcePermission = useCallback(async () => { + setPermissionsLoading(true); try { const permission = await getEntityPermissionByFqn( ResourceEntity.STORED_PROCEDURE, @@ -147,9 +251,9 @@ const StoredProcedurePage = () => { }) ); } finally { - setIsLoading(false); + setPermissionsLoading(false); } - }, [getEntityPermissionByFqn]); + }, [getEntityPermissionByFqn, decodedStoredProcedureFQN, t]); const handleFeedCount = useCallback((data: FeedCounts) => { setFeedCount(data); @@ -163,35 +267,21 @@ const StoredProcedurePage = () => { ); }; - const fetchStoredProcedureDetails = async () => { - setIsLoading(true); - try { - const response = await getStoredProceduresByFqn( + const fetchTaskCounts = useCallback(() => { + if (decodedStoredProcedureFQN) { + fetchEntityTaskCountsInto(decodedStoredProcedureFQN, setFeedCount); + } + }, [decodedStoredProcedureFQN]); + + const fetchActivityCount = useCallback(() => { + if (decodedStoredProcedureFQN) { + fetchEntityActivityCountInto( + EntityType.STORED_PROCEDURE, decodedStoredProcedureFQN, - { - fields: STORED_PROCEDURE_DEFAULT_FIELDS, - include: Include.All, - } + setFeedCount ); - - setStoredProcedure(response); - - addToRecentViewed({ - displayName: getEntityName(response), - entityType: EntityType.STORED_PROCEDURE, - fqn: response.fullyQualifiedName ?? '', - serviceType: response.serviceType, - timestamp: 0, - id: response.id ?? '', - }); - } catch (error) { - if ((error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } - } finally { - setIsLoading(false); } - }; + }, [decodedStoredProcedureFQN]); const versionHandler = useCallback(() => { version && @@ -239,56 +329,75 @@ const StoredProcedurePage = () => { } }; - const followEntity = useCallback(async () => { - try { - const res = await addStoredProceduresFollower(storedProcedureId, USER_ID); - const { newValue } = res.changeDescription.fieldsAdded[0]; - const newFollowers = [...(followers ?? []), ...newValue]; - setStoredProcedure((prev) => { - if (!prev) { - return prev; - } - - return { ...prev, followers: newFollowers }; - }); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(storedProcedure), - }) + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: StoredProcedure | undefined } + >({ + mutationFn: async () => { + if (!storedProcedureId) { + return; + } + if (isFollowing) { + await removeStoredProceduresFollower(storedProcedureId, USER_ID); + } else { + await addStoredProceduresFollower(storedProcedureId, USER_ID); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: storedProcedureCacheKey }); + const previous = queryClient.getQueryData( + storedProcedureCacheKey ); - } - }, [USER_ID, followers, storedProcedure, storedProcedureId]); + queryClient.setQueryData( + storedProcedureCacheKey, + (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USER_ID), + }; + } - const unFollowEntity = useCallback(async () => { - try { - const res = await removeStoredProceduresFollower( - storedProcedureId, - USER_ID - ); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setStoredProcedure((pre) => { - if (!pre) { - return pre; + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USER_ID, type: 'user' }, + ] as StoredProcedure['followers'], + }; } + ); - return { - ...pre, - followers: pre.followers?.filter( - (follower) => follower.id !== oldValue[0].id - ), - }; - }); - } catch (error) { + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + storedProcedureCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(storedProcedure), - }) + isFollowing + ? t('server.entity-unfollow-error', { + entity: getEntityName(storedProcedure), + }) + : t('server.entity-follow-error', { + entity: getEntityName(storedProcedure), + }) ); - } - }, [USER_ID, storedProcedureId]); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: storedProcedureCacheKey }); + }, + }); const handleDisplayNameUpdate = async (data: EntityName) => { if (!storedProcedure) { @@ -299,8 +408,8 @@ const StoredProcedurePage = () => { }; const handleFollow = useCallback(async () => { - isFollowing ? await unFollowEntity() : await followEntity(); - }, [isFollowing]); + await followMutation.mutateAsync(); + }, [followMutation]); const handleUpdateOwner = useCallback( async (newOwner?: StoredProcedure['owners']) => { @@ -371,14 +480,17 @@ const StoredProcedurePage = () => { [navigate] ); - const afterDomainUpdateAction = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as StoredProcedure; + const afterDomainUpdateAction = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as StoredProcedure; - setStoredProcedure((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + setStoredProcedure((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setStoredProcedure] + ); const handleTabChange = (activeKey: EntityTabs) => { if (activeKey !== activeTab) { @@ -411,47 +523,7 @@ const StoredProcedurePage = () => { }); } }, - [saveUpdatedStoredProceduresData, storedProcedure] - ); - - const { - editCustomAttributePermission, - editLineagePermission, - viewAllPermission, - viewBasicPermission, - viewCustomPropertiesPermission, - } = useMemo( - () => ({ - editTagsPermission: - (storedProcedurePermissions.EditTags || - storedProcedurePermissions.EditAll) && - !storedProcedure?.deleted, - editGlossaryTermsPermission: - (storedProcedurePermissions.EditGlossaryTerms || - storedProcedurePermissions.EditAll) && - !deleted, - editDescriptionPermission: - (storedProcedurePermissions.EditDescription || - storedProcedurePermissions.EditAll) && - !storedProcedure?.deleted, - editCustomAttributePermission: - (storedProcedurePermissions.EditAll || - storedProcedurePermissions.EditCustomFields) && - !storedProcedure?.deleted, - editLineagePermission: - (storedProcedurePermissions.EditAll || - storedProcedurePermissions.EditLineage) && - !storedProcedure?.deleted, - viewAllPermission: storedProcedurePermissions.ViewAll, - viewBasicPermission: - storedProcedurePermissions.ViewAll || - storedProcedurePermissions.ViewBasic, - viewCustomPropertiesPermission: getPrioritizedViewPermission( - storedProcedurePermissions, - Operation.ViewCustomFields - ), - }), - [storedProcedurePermissions, storedProcedure] + [saveUpdatedStoredProceduresData, storedProcedure, setStoredProcedure] ); const tabs = useMemo(() => { @@ -472,7 +544,7 @@ const StoredProcedurePage = () => { viewCustomPropertiesPermission, onExtensionUpdate, getEntityFeedCount: getEntityFeedCount, - fetchStoredProcedureDetails, + fetchStoredProcedureDetails: refetchStoredProcedureDetails, handleFeedCount: handleFeedCount, labelMap: tabLabelMap, }); @@ -499,7 +571,7 @@ const StoredProcedurePage = () => { viewCustomPropertiesPermission, onExtensionUpdate, getEntityFeedCount, - fetchStoredProcedureDetails, + refetchStoredProcedureDetails, handleFeedCount, ]); @@ -516,13 +588,9 @@ const StoredProcedurePage = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updateStoredProcedureVotes(id, data); - const details = await getStoredProceduresByFqn( - decodedStoredProcedureFQN, - { - fields: STORED_PROCEDURE_DEFAULT_FIELDS, - } - ); - setStoredProcedure(details); + await queryClient.invalidateQueries({ + queryKey: storedProcedureCacheKey, + }); } catch (error) { showErrorToast(error as AxiosError); } @@ -546,6 +614,7 @@ const StoredProcedurePage = () => { }, [storedProcedure, handleStoreProcedureUpdate] ); + useEffect(() => { if (decodedStoredProcedureFQN) { fetchResourcePermission(); @@ -554,12 +623,12 @@ const StoredProcedurePage = () => { useEffect(() => { if (viewBasicPermission) { - fetchStoredProcedureDetails(); - getEntityFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); } - }, [decodedStoredProcedureFQN, storedProcedurePermissions]); + }, [decodedStoredProcedureFQN, viewBasicPermission]); - if (isLoading || loading) { + if (permissionsLoading || loading || storedProcedureLoading) { return ; } @@ -575,7 +644,7 @@ const StoredProcedurePage = () => { ); } - if (!storedProcedure) { + if (storedProcedureError || !storedProcedure) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx index 960f94e209d0..5445f5b30431 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, render, screen } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { GenericTab } from '../../components/Customization/GenericTab/GenericTab'; @@ -18,6 +18,7 @@ import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { TableType } from '../../generated/entity/data/table'; import { getTableDetailsByFQN } from '../../rest/tableAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import TableDetailsPageV1 from './TableDetailsPageV1'; @@ -139,6 +140,9 @@ jest.mock('../../rest/suggestionsAPI', () => ({ })); jest.mock('../../utils/CommonUtils', () => ({ + addToRecentViewed: jest.fn(), + fetchEntityActivityCountInto: jest.fn(), + fetchEntityTaskCountsInto: jest.fn(), getFeedCounts: jest.fn(), getPartialNameFromTableFQN: jest.fn().mockImplementation(() => 'fqn'), getTableFQNFromColumnFQN: jest.fn(), @@ -320,7 +324,7 @@ jest.mock( describe('TestDetailsPageV1 component', () => { it('TableDetailsPageV1 should fetch permissions', () => { - render( + renderWithQueryClient( @@ -330,7 +334,7 @@ describe('TestDetailsPageV1 component', () => { }); it('TableDetailsPageV1 should not fetch table details if permission is there', () => { - render( + renderWithQueryClient( @@ -347,7 +351,7 @@ describe('TestDetailsPageV1 component', () => { })); await act(async () => { - render( + renderWithQueryClient( @@ -369,7 +373,7 @@ describe('TestDetailsPageV1 component', () => { })); await act(async () => { - render( + renderWithQueryClient( @@ -389,7 +393,7 @@ describe('TestDetailsPageV1 component', () => { })); await act(async () => { - render( + renderWithQueryClient( @@ -435,7 +439,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -464,7 +468,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -493,7 +497,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -522,7 +526,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -551,7 +555,7 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( @@ -579,14 +583,20 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( ); }); - expect(screen.getByText('label.schema-definition')).toBeInTheDocument(); + // useQuery resolves its promise on a microtask after the initial render — use findByText + // (waits up to the testing-library default timeout) rather than getByText, which would + // otherwise race the cache settle. The act-wrapper flushes effects but not the chained + // promise inside react-query's internal scheduler. + expect( + await screen.findByText('label.schema-definition') + ).toBeInTheDocument(); expect(screen.queryByText('label.dbt-lowercase')).not.toBeInTheDocument(); }); @@ -608,14 +618,16 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( ); }); - expect(screen.getByText('label.view-definition')).toBeInTheDocument(); + expect( + await screen.findByText('label.view-definition') + ).toBeInTheDocument(); }); it('TableDetailsPageV1 should render schemaTab by default', async () => { @@ -626,7 +638,7 @@ describe('TestDetailsPageV1 component', () => { })); await act(async () => { - render( + renderWithQueryClient( @@ -659,18 +671,23 @@ describe('TestDetailsPageV1 component', () => { ); await act(async () => { - render( + renderWithQueryClient( ); }); - expect(PageLayoutV1).toHaveBeenCalledWith( - expect.objectContaining({ - pageTitle: 'test-table', - }), - expect.anything() + // Same reason as the schema-definition test above — useQuery's data is available on a + // subsequent render, not immediately after `act` flushes. waitFor polls until the page + // re-renders with the resolved title. + await waitFor(() => + expect(PageLayoutV1).toHaveBeenCalledWith( + expect.objectContaining({ + pageTitle: 'test-table', + }), + expect.anything() + ) ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index 966e5ead5f89..552c1187e7a0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Col, Row, Tabs, Tooltip } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; @@ -33,7 +34,6 @@ import { EntityName } from '../../components/Modals/EntityNameModal/EntityNameMo import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { ROUTES } from '../../constants/constants'; import { FEED_COUNT_INITIAL_DATA } from '../../constants/entity.constants'; -import { mockDatasetData } from '../../constants/mockTourData.constants'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { OperationPermission, @@ -56,31 +56,34 @@ import { TagLabel } from '../../generated/type/tagLabel'; import LimitWrapper from '../../hoc/LimitWrapper'; import { useApplicationStore } from '../../hooks/useApplicationStore'; import { useCustomPages } from '../../hooks/useCustomPages'; +import { useDeferredTabData } from '../../hooks/useDeferredTabData'; import { useFqn } from '../../hooks/useFqn'; import { useSub } from '../../hooks/usePubSub'; import { FeedCounts } from '../../interface/feed.interface'; import { fetchTestCaseResultByTestSuiteId } from '../../rest/dataQualityDashboardAPI'; import { getDataQualityLineage } from '../../rest/lineageAPI'; +import { tableQueryFn, tableQueryKey } from '../../rest/queries/tableQuery'; import { getQueriesList } from '../../rest/queryAPI'; import { addFollower, - getTableDetailsByFQN, patchTableDetails, removeFollower, restoreTable, updateTablesVotes, } from '../../rest/tableAPI'; import { Suggestion, SuggestionType } from '../../types/taskSuggestion'; -import { addToRecentViewed, getFeedCounts } from '../../utils/CommonUtils'; +import { + addToRecentViewed, + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getFeedCounts, +} from '../../utils/CommonUtils'; import { checkIfExpandViewSupported, getDetailsTabWithNewLabel, getTabLabelMapFromTabs, } from '../../utils/CustomizePage/CustomizePageUtils'; -import { - defaultFields, - defaultFieldsWithColumns, -} from '../../utils/DatasetDetailsUtils'; +import { defaultFieldsWithColumns } from '../../utils/DatasetDetailsUtils'; import { mergeEntityStateUpdate } from '../../utils/EntityUpdateUtils'; import entityUtilClassBase from '../../utils/EntityUtilClassBase'; import { getEntityName } from '../../utils/EntityUtils'; @@ -108,7 +111,7 @@ const TableDetailsPageV1: React.FC = () => { useTourProvider(); const { currentUser } = useApplicationStore(); const { setDqLineageData } = useTestCaseStore(); - const [tableDetails, setTableDetails] = useState(); + const queryClient = useQueryClient(); const { tab: activeTab } = useRequiredParams<{ tab: EntityTabs }>(); const { t } = useTranslation(); const navigate = useNavigate(); @@ -120,10 +123,10 @@ const TableDetailsPageV1: React.FC = () => { const [queryCount, setQueryCount] = useState(0); - const [loading, setLoading] = useState(!isTourOpen); const [tablePermissions, setTablePermissions] = useState( DEFAULT_ENTITY_PERMISSION ); + const [permissionsLoading, setPermissionsLoading] = useState(!isTourOpen); const [dqFailureCount, setDqFailureCount] = useState(0); const { customizedPage } = useCustomPages(PageType.Table); const [isTabExpanded, setIsTabExpanded] = useState(false); @@ -156,20 +159,6 @@ const TableDetailsPageV1: React.FC = () => { ) : undefined; }, [dqFailureCount, tableFqn]); - const extraDropdownContent = useMemo( - () => - tableDetails - ? entityUtilClassBase.getManageExtraOptions( - EntityType.TABLE, - tableFqn, - tablePermissions, - tableDetails, - navigate - ) - : [], - [tablePermissions, tableFqn, tableDetails] - ); - const { viewUsagePermission, viewTestCasePermission } = useMemo( () => ({ viewUsagePermission: getPrioritizedViewPermission( @@ -188,49 +177,137 @@ const TableDetailsPageV1: React.FC = () => { ] ); - const isViewTableType = useMemo( - () => tableDetails?.tableType === TableType.View, - [tableDetails?.tableType] + // Field set the page reads from the server. The permission-gated extras (USAGE_SUMMARY, + // TESTSUITE) become part of the React Query cache key so a permission flip doesn't serve + // a "lite" cached body to a "heavy" caller. {@link tableQueryKey} also covers the + // fqn axis so navigating between tables hits a fresh slot. + const tableFields = useMemo(() => { + let fields = defaultFieldsWithColumns; + if (viewUsagePermission) { + fields += `,${TabSpecificField.USAGE_SUMMARY}`; + } + if (viewTestCasePermission) { + fields += `,${TabSpecificField.TESTSUITE}`; + } + + return fields; + }, [viewUsagePermission, viewTestCasePermission]); + + const tableCacheKey = useMemo( + () => tableQueryKey(tableFqn, tableFields), + [tableFqn, tableFields] ); - const fetchTableDetails = useCallback( - async (showLoading = true) => { - if (showLoading) { - setLoading(true); - } - try { - let fields = defaultFieldsWithColumns; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; - } - if (viewTestCasePermission) { - fields += `,${TabSpecificField.TESTSUITE}`; - } + // {@code viewBasicPermission} is computed by a later useMemo over {@code tablePermissions}, + // but the useQuery below needs to gate on it. Compute the same value inline here from the + // raw {@code tablePermissions} state so the query can be declared before the larger + // permissions useMemo (avoids a use-before-declaration hoisting error). + const canViewTableInQuery = useMemo( + () => + getPrioritizedViewPermission(tablePermissions, Operation.ViewBasic) === + true, + [tablePermissions] + ); - const tableDetails = await getTableDetailsByFQN(tableFqn, { fields }); + // P2: replace the manual useState + fetchTableDetails + useEffect pattern with + // {@link useQuery}. Wins: + // - Background revalidation: a stale entry serves immediately, then refetches; the page + // is interactive on first paint when the entry is fresh. + // - Single source of truth: hover-prefetch from search/recently-viewed (P3) populates the + // same cache slot, so the page mount is free in that case. + // - Mutations apply optimistic updates via {@code queryClient.setQueryData}; every + // consumer reading the same key sees the change instantly (no prop drilling). + // + // {@code enabled} gates the fire so we don't fetch in tour mode (we seed the mock directly + // below) or before view permissions have resolved. The tour case writes to the cache via + // {@code setQueryData} so {@code tableDetails} below stays one variable. + const { + data: tableDetails, + isLoading: tableLoading, + error: tableError, + } = useQuery({ + queryKey: tableCacheKey, + queryFn: tableQueryFn(tableFqn, tableFields), + enabled: Boolean( + tableFqn && canViewTableInQuery && !isTourOpen && !isTourPage + ), + }); + + // Forbidden → redirect, preserving the prior behavior. Run as an effect rather than during + // render so the navigate call doesn't fire during the same commit that returns the query + // result. Only redirects on a fresh 403; if the user has stale cached data and the server + // later 403s on background refetch, we don't yank them off the page. + useEffect(() => { + const status = (tableError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } + }, [tableError, navigate]); - setTableDetails(tableDetails); - addToRecentViewed({ - displayName: getEntityName(tableDetails), - entityType: EntityType.TABLE, - fqn: tableDetails.fullyQualifiedName ?? '', - serviceType: tableDetails.serviceType, - timestamp: 0, - id: tableDetails.id, - }); - } catch (error) { - if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } - } finally { - if (showLoading) { - setLoading(false); - } + // Side effect that used to live in the fetch callback — populate "recently viewed" on a + // successful fetch. Decoupled from the fetch so it fires when the cache resolves the data, + // whether that's via a network fetch or a hover-prefetch hit. + useEffect(() => { + if (!tableDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(tableDetails), + entityType: EntityType.TABLE, + fqn: tableDetails.fullyQualifiedName ?? '', + serviceType: tableDetails.serviceType, + timestamp: 0, + id: tableDetails.id, + }); + }, [tableDetails]); + + // Imperative cache writer for mutation handlers. Functionally identical to the old + // {@code setTableDetails(updater)} — accepts a (Table | undefined) → (Table | undefined) + // and writes the result into the cache so every reader (this page, hover-prefetched + // siblings, future widgets that consume the key) sees the update. + const setTableDetails = useCallback( + ( + updater: + | Table + | undefined + | ((prev: Table | undefined) => Table | undefined) + ) => { + if (typeof updater === 'function') { + queryClient.setQueryData
(tableCacheKey, updater); + } else { + queryClient.setQueryData
(tableCacheKey, updater); } }, - [tableFqn, viewUsagePermission] + [queryClient, tableCacheKey] + ); + + // Replacement for the old {@code fetchTableDetails()} call sites that want a fresh body + // (e.g. after a server-side mutation we can't represent purely on the client). Triggers a + // background refetch — stale data continues to render until the new body arrives. + const refetchTableDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: tableCacheKey }), + [queryClient, tableCacheKey] + ); + + const isViewTableType = useMemo( + () => tableDetails?.tableType === TableType.View, + [tableDetails?.tableType] + ); + + // Lifted from above the useQuery block: depends on {@code tableDetails} so must come + // after the query is declared. Same shape as before. + const extraDropdownContent = useMemo( + () => + tableDetails + ? entityUtilClassBase.getManageExtraOptions( + EntityType.TABLE, + tableFqn, + tablePermissions, + tableDetails, + navigate + ) + : [], + [tablePermissions, tableFqn, tableDetails, navigate] ); const fetchDQUpstreamFailureCount = async () => { @@ -353,7 +430,7 @@ const TableDetailsPageV1: React.FC = () => { }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }, [getEntityPermissionByFqn, setTablePermissions] @@ -377,6 +454,21 @@ const TableDetailsPageV1: React.FC = () => { getFeedCounts(EntityType.TABLE, tableFqn, handleFeedCount); }; + // P2-A: task counts drive the always-visible "Open Tasks" button in the page header chrome, + // so they must stay eager on mount. The heavier activity-events fetch (up to 100 events just + // to compute a count) only feeds the Activity Feed tab badge and is deferred below. + const fetchTaskCounts = useCallback(() => { + if (tableFqn) { + fetchEntityTaskCountsInto(tableFqn, setFeedCount); + } + }, [tableFqn]); + + const fetchActivityCount = useCallback(() => { + if (tableFqn) { + fetchEntityActivityCountInto(EntityType.TABLE, tableFqn, setFeedCount); + } + }, [tableFqn]); + const handleTabChange = (activeKey: string) => { if (activeKey !== activeTab) { if (!isTourOpen) { @@ -543,7 +635,7 @@ const TableDetailsPageV1: React.FC = () => { viewQueriesPermission, viewProfilerPermission, editLineagePermission, - fetchTableDetails, + fetchTableDetails: refetchTableDetails, isViewTableType, labelMap: tabLabelMap, columnFqn, @@ -575,7 +667,7 @@ const TableDetailsPageV1: React.FC = () => { viewQueriesPermission, viewProfilerPermission, editLineagePermission, - fetchTableDetails, + refetchTableDetails, isViewTableType, columnFqn, columnPart, @@ -586,9 +678,18 @@ const TableDetailsPageV1: React.FC = () => { [tabs[0], activeTab] ); - const handleTableSync = useCallback((updatedTable: Table) => { - setTableDetails(updatedTable); - }, []); + // {@code setTableDetails} is a closure over {@code tableCacheKey}, which itself depends on + // {@code tableFields} (and therefore on the permission-derived USAGE_SUMMARY/TESTSUITE + // extras). If permissions resolve after first render the cache key shifts; a stale closure + // here would keep writing to the OLD slot while {@code useQuery} reads the NEW slot, so + // entity-sync updates would silently no-op on screen. Including {@code setTableDetails} in + // the deps re-binds the handler to the current slot. + const handleTableSync = useCallback( + (updatedTable: Table) => { + setTableDetails(updatedTable); + }, + [setTableDetails] + ); const onTierUpdate = useCallback( async (newTier?: Tag) => { @@ -655,63 +756,92 @@ const TableDetailsPageV1: React.FC = () => { } }; - const followTable = useCallback(async () => { - try { - const res = await addFollower(tableId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - const newFollowers = [...(followers ?? []), ...newValue]; - setTableDetails((prev) => { + const { isFollowing } = useMemo(() => { + return { + isFollowing: followers?.some(({ id }) => id === USERId), + }; + }, [followers, USERId]); + + // Optimistic follow/unfollow. Why this matters: the prior code awaited the PUT round-trip + // before flipping the button text, so users saw 200–800 ms of "did my click register?" + // every time. {@code onMutate} patches the cache synchronously so the button updates on + // the SAME render that fires the network call; {@code onError} rolls back if the request + // fails; {@code onSettled} invalidates the key so a background refetch picks up any + // additional server-side state (e.g. timestamps). + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Table | undefined } + >({ + mutationFn: async () => { + if (isFollowing) { + await removeFollower(tableId, USERId); + } else { + await addFollower(tableId, USERId); + } + }, + onMutate: async () => { + // Cancel any in-flight refetch so it doesn't overwrite our optimistic patch. + await queryClient.cancelQueries({ queryKey: tableCacheKey }); + const previous = queryClient.getQueryData
( + tableCacheKey + ); + queryClient.setQueryData
(tableCacheKey, (prev) => { if (!prev) { return prev; } - - return { ...prev, followers: newFollowers }; - }); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: entityName, - }) - ); - } - }, [USERId, tableId, entityName, setTableDetails]); - - const unFollowTable = useCallback(async () => { - try { - const res = await removeFollower(tableId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setTableDetails((pre) => { - if (!pre) { - return pre; + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; } return { - ...pre, - followers: pre.followers?.filter( - (follower) => follower.id !== oldValue[0].id - ), + ...prev, + followers: [ + ...currentFollowers, + // Minimal EntityReference patch; the real shape arrives on settle. The header + // only reads {@code .id} to decide isFollowing, so the partial is sufficient. + { id: USERId, type: 'user' }, + ] as Table['followers'], }; }); - } catch (error) { + + return { previous }; + }, + onError: (error, _variables, context) => { + // Roll back to the pre-mutation snapshot and surface the right error message. + if (context?.previous !== undefined) { + queryClient.setQueryData
( + tableCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: entityName, - }) + isFollowing + ? t('server.entity-unfollow-error', { entity: entityName }) + : t('server.entity-follow-error', { entity: entityName }) ); - } - }, [USERId, tableId, entityName, setTableDetails]); - - const { isFollowing } = useMemo(() => { - return { - isFollowing: followers?.some(({ id }) => id === USERId), - }; - }, [followers, USERId]); + }, + onSettled: () => { + // Background refetch picks up server-side changes we didn't represent (e.g. the new + // entry's authoritative type/displayName). Stale data continues to render during the + // refetch, so this is invisible to the user. + queryClient.invalidateQueries({ queryKey: tableCacheKey }); + }, + }); + // {@code onFollowClick} on {@code DataAssetsHeader} is typed as a {@code () => Promise} + // so we wrap {@code mutate} in {@code mutateAsync} (which returns the promise) to satisfy + // the type. The optimistic cache patch in {@code onMutate} fires synchronously regardless; + // awaiting just keeps the prop contract intact for callers that chain off the click. const handleFollowTable = useCallback(async () => { - isFollowing ? await unFollowTable() : await followTable(); - }, [isFollowing, unFollowTable, followTable]); + await followMutation.mutateAsync(); + }, [followMutation]); const versionHandler = useCallback(() => { version && @@ -723,14 +853,17 @@ const TableDetailsPageV1: React.FC = () => { [] ); - const updateTableDetailsState = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as Table; + const updateTableDetailsState = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as Table; - setTableDetails((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + setTableDetails((data) => ({ + ...(updatedData ?? data), + version: updatedData.version, + })); + }, + [setTableDetails] + ); const updateDescriptionTagFromSuggestions = useCallback( (suggestion: Suggestion) => { @@ -772,26 +905,57 @@ const TableDetailsPageV1: React.FC = () => { } }); }, - [] + [setTableDetails] ); useEffect(() => { if (isTourOpen || isTourPage) { - setTableDetails(mockDatasetData.tableDetails as unknown as Table); + // Seed the cache with the tour mock so the rest of the page reads through the same + // useQuery slot. The {@link useQuery} hook is {@code enabled: false} in tour mode, so + // this manual write is the only thing that populates the slot. The tour mock data is + // ~113 KB so we lazy-load it only when actually in tour mode. + import('../../constants/mockTourData.constants').then( + ({ mockDatasetData }) => { + setTableDetails(mockDatasetData.tableDetails as unknown as Table); + } + ); } else if (viewBasicPermission) { - setTableDetails(undefined); - fetchTableDetails(); - getEntityFeedCount(); + // Don't manually clear the cache to {@code undefined} here — that would flash a Loader + // on every navigation between tables even when the destination is already cached. + // {@link useQuery}'s own refetch-on-key-change handles this: a stale entry serves + // immediately while a background refresh runs. + fetchTaskCounts(); + fetchActivityCount(); } }, [tableFqn, isTourOpen, isTourPage, viewBasicPermission]); + // P1.2: getTestCaseFailureCount drives the global red-alert badge in the page chrome, + // so it must run as soon as tableDetails resolves — deferring would mean the user could + // miss a critical "this dataset has failing tests" indicator on first paint. useEffect(() => { if (tableDetails) { - fetchQueryCount(); getTestCaseFailureCount(); } }, [tableDetails?.fullyQualifiedName]); + // P1.2: queryCount only drives the "Queries (N)" tab badge — most users never click that + // tab, so eagerly fetching it on every page load wasted a server round-trip per view. + // Defer until the user actually activates the Queries tab (or any of its column-scoped + // sub-tabs); the badge then populates on first activation. {@link useDeferredTabData} + // also re-fires on FQN change if the user is already on the Queries tab, so badge counts + // never show stale data from a previous entity. + useDeferredTabData(EntityTabs.TABLE_QUERIES, activeTab, fetchQueryCount, [ + tableDetails?.fullyQualifiedName, + ]); + + // Reset the badge count to 0 when navigating to a different entity. Without this the + // badge would show the previous table's queryCount until the deferred fetch resolves, + // which is briefly misleading when navigating between tables that have differing query + // counts. + useEffect(() => { + setQueryCount(0); + }, [tableDetails?.fullyQualifiedName]); + useSub( 'updateDetails', (suggestion: Suggestion) => { @@ -803,12 +967,12 @@ const TableDetailsPageV1: React.FC = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updateTablesVotes(id, data); - let fields = defaultFields; - if (viewUsagePermission) { - fields += `,${TabSpecificField.USAGE_SUMMARY}`; - } - const details = await getTableDetailsByFQN(tableFqn, { fields }); - setTableDetails(details); + // Server-side {@code updateVote} mutates a relationship only — the rest of the entity + // is unchanged. Invalidate the cache slot so the next read picks up the new vote totals + // (a focused refetch instead of the prior full-defaultFields refetch that overwrote + // every other field too). Background revalidation keeps current data on screen until + // the new body arrives. + await queryClient.invalidateQueries({ queryKey: tableCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } @@ -818,7 +982,10 @@ const TableDetailsPageV1: React.FC = () => { setIsTabExpanded((prev) => !prev); }; - if (loading) { + // Wait for permissions to resolve before deciding what to render — without this we'd flash + // a "no permission" placeholder during the brief window before the permissions endpoint + // returns. Once permissions are in, this gate falls through naturally. + if (permissionsLoading) { return ; } @@ -834,6 +1001,16 @@ const TableDetailsPageV1: React.FC = () => { ); } + // Still loading the entity itself — useQuery is mid-flight or hasn't started (e.g. the + // FQN just changed and the new cache slot is empty). Distinct from the permission gate + // above so we keep the loader spinning instead of flashing the missing-entity placeholder. + if (tableLoading) { + return ; + } + + // Fetch completed but no entity body — typically a 404 (invalid FQN) or a network error + // that {@code tableError} surfaced. Show the missing-entity placeholder instead of + // looping on the loader (the original page used a separate gate for this). if (!tableDetails) { return ; } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.test.tsx index 5a8fc94bd08e..0e37a6f60f18 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.test.tsx @@ -11,15 +11,18 @@ * limitations under the License. */ -import { render, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { EntityTabs } from '../../enums/entity.enum'; import { useFqn } from '../../hooks/useFqn'; import { searchQuery } from '../../rest/searchAPI'; import { getTagByFqn } from '../../rest/tagAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import tagClassBase from '../../utils/TagClassBase'; import { useRequiredParams } from '../../utils/useRequiredParams'; import TagPage from './TagPage'; +const render = renderWithQueryClient; + jest.mock('@openmetadata/ui-core-components', () => ({ Button: jest .fn() diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx index 0dd463ba736b..8e2eab6fdb5b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagPage/TagPage.tsx @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Button, Col, @@ -81,11 +82,7 @@ import { ResourceEntity, } from '../../context/PermissionProvider/PermissionProvider.interface'; import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; -import { - EntityTabs, - EntityType, - TabSpecificField, -} from '../../enums/entity.enum'; +import { EntityTabs, EntityType } from '../../enums/entity.enum'; import { SearchIndex } from '../../enums/search.enum'; import { ProviderType, Tag } from '../../generated/entity/classification/tag'; import { EntityStatus } from '../../generated/entity/data/glossaryTerm'; @@ -94,9 +91,20 @@ import { Style } from '../../generated/type/tagLabel'; import { useCustomPages } from '../../hooks/useCustomPages'; import { useFqn } from '../../hooks/useFqn'; import { FeedCounts } from '../../interface/feed.interface'; +import { + tagQueryFn, + tagQueryKey, + TAG_DEFAULT_FIELDS, +} from '../../rest/queries/tagQuery'; import { searchQuery } from '../../rest/searchAPI'; -import { deleteTag, getTagByFqn, patchTag } from '../../rest/tagAPI'; -import { getEntityDeleteMessage, getFeedCounts } from '../../utils/CommonUtils'; +import { deleteTag, patchTag } from '../../rest/tagAPI'; +import { + fetchEntityActivityCountInto, + fetchEntityTaskCountsInto, + getEntityDeleteMessage, + getEntityMissingError, + getFeedCounts, +} from '../../utils/CommonUtils'; import entityUtilClassBase from '../../utils/EntityUtilClassBase'; import { renderIcon } from '../../utils/IconUtils'; import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; @@ -118,6 +126,7 @@ const TagPage = () => { const { t } = useTranslation(); const { fqn: tagFqn } = useFqn(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { tab: activeTab = EntityTabs.OVERVIEW } = useRequiredParams<{ tab?: string; }>(); @@ -125,8 +134,6 @@ const TagPage = () => { const { customizedPage, isLoading: isCustomPageLoading } = useCustomPages( PageType.Tag ); - const [isLoading, setIsLoading] = useState(true); - const [tagItem, setTagItem] = useState(); const [assetModalVisible, setAssetModalVisible] = useState(false); const [isNameEditing, setIsNameEditing] = useState(false); @@ -143,6 +150,48 @@ const TagPage = () => { const [feedCount, setFeedCount] = useState( FEED_COUNT_INITIAL_DATA ); + + const tagCacheKey = useMemo( + () => tagQueryKey(tagFqn, TAG_DEFAULT_FIELDS), + [tagFqn] + ); + + const { + data: tagItem, + isLoading: tagLoading, + error: tagError, + } = useQuery({ + queryKey: tagCacheKey, + queryFn: tagQueryFn(tagFqn, TAG_DEFAULT_FIELDS), + enabled: Boolean(tagFqn), + }); + + const isError = useMemo( + () => (tagError as AxiosError | undefined)?.response?.status === 404, + [tagError] + ); + + useEffect(() => { + const status = (tagError as AxiosError | undefined)?.response?.status; + if (tagError && status !== 404) { + showErrorToast(tagError as AxiosError); + } + }, [tagError]); + + const setTagItem = useCallback( + ( + updater: Tag | undefined | ((prev: Tag | undefined) => Tag | undefined) + ) => { + queryClient.setQueryData(tagCacheKey, updater); + }, + [queryClient, tagCacheKey] + ); + + const refetchTagItem = useCallback( + () => queryClient.invalidateQueries({ queryKey: tagCacheKey }), + [queryClient, tagCacheKey] + ); + const breadcrumb: TitleBreadcrumbProps['titleLinks'] = useMemo(() => { return tagItem ? [ @@ -228,7 +277,7 @@ const TagPage = () => { [tagPermissions.EditAll, tagItem?.deleted] ); - const fetchCurrentTagPermission = async () => { + const fetchCurrentTagPermission = useCallback(async () => { if (!tagItem?.id) { return; } @@ -241,27 +290,7 @@ const TagPage = () => { } catch (error) { showErrorToast(error as AxiosError); } - }; - - const getTagData = async () => { - try { - setIsLoading(true); - if (tagFqn) { - const response = await getTagByFqn(tagFqn, { - fields: [ - TabSpecificField.DOMAINS, - TabSpecificField.OWNERS, - TabSpecificField.REVIEWERS, - ], - }); - setTagItem(response); - } - } catch (e) { - showErrorToast(e as AxiosError); - } finally { - setIsLoading(false); - } - }; + }, [tagItem?.id, getEntityPermission]); const activeTabHandler = (tab: string) => { if (tagItem) { @@ -279,19 +308,22 @@ const TagPage = () => { } }; - const updateTag = async (updatedData: Tag) => { - if (tagItem) { - const jsonPatch = compare(tagItem, updatedData); + const updateTag = useCallback( + async (updatedData: Tag) => { + if (tagItem) { + const jsonPatch = compare(tagItem, updatedData); - try { - const response = await patchTag(tagItem.id ?? '', jsonPatch); + try { + const response = await patchTag(tagItem.id ?? '', jsonPatch); - setTagItem(response); - } catch (error) { - showErrorToast(error as AxiosError); + setTagItem(response); + } catch (error) { + showErrorToast(error as AxiosError); + } } - } - }; + }, + [tagItem, setTagItem] + ); const onNameSave = async (obj: Tag) => { if (tagItem) { @@ -344,7 +376,6 @@ const TagPage = () => { entity: t('label.tag-lowercase'), }) ); - setIsLoading(true); if (tagItem?.classification?.fullyQualifiedName) { navigate( @@ -374,7 +405,7 @@ const TagPage = () => { navigate(ROUTES.TAGS); }; - const fetchClassificationTagAssets = async () => { + const fetchClassificationTagAssets = useCallback(async () => { try { const res = await searchQuery({ query: '', @@ -397,7 +428,7 @@ const TagPage = () => { ); setAssetCount(0); } - }; + }, [tagFqn, t]); const fetchFeedCount = async () => { if (tagItem?.fullyQualifiedName) { @@ -409,6 +440,22 @@ const TagPage = () => { } }; + const fetchTaskCounts = useCallback(() => { + if (tagItem?.fullyQualifiedName) { + fetchEntityTaskCountsInto(tagItem.fullyQualifiedName, setFeedCount); + } + }, [tagItem?.fullyQualifiedName]); + + const fetchActivityCount = useCallback(() => { + if (tagItem?.fullyQualifiedName) { + fetchEntityActivityCountInto( + EntityType.TAG, + tagItem.fullyQualifiedName, + setFeedCount + ); + } + }, [tagItem?.fullyQualifiedName]); + const handleAssetSave = useCallback(() => { fetchClassificationTagAssets(); assetTabRef.current?.refreshAssets(); @@ -604,7 +651,7 @@ const TagPage = () => { owners={tagItem.owners} subTab={ActivityFeedTabs.ALL} onFeedUpdate={fetchFeedCount} - onUpdateEntityDetails={getTagData} + onUpdateEntityDetails={refetchTagItem} onUpdateFeedCount={handleFeedCount} /> ), @@ -654,6 +701,7 @@ const TagPage = () => { handleAssetSave, handleAssetClick, handleFeedCount, + refetchTagItem, assetTabRef, t, ]); @@ -714,21 +762,29 @@ const TagPage = () => { }, [tagItem]); useEffect(() => { - getTagData(); fetchClassificationTagAssets(); - }, [tagFqn]); + }, [fetchClassificationTagAssets]); useEffect(() => { if (tagItem) { fetchCurrentTagPermission(); - fetchFeedCount(); + fetchTaskCounts(); + fetchActivityCount(); } - }, [tagItem]); + }, [tagItem, fetchCurrentTagPermission, fetchTaskCounts, fetchActivityCount]); - if (isLoading || isCustomPageLoading) { + if (tagLoading || isCustomPageLoading) { return ; } + if (isError) { + return ( + + {getEntityMissingError('tag', tagFqn)} + + ); + } + if (!tagItem) { return ( { const USERId = currentUser?.id ?? ''; const navigate = useNavigate(); const { getEntityPermissionByFqn } = usePermissionProvider(); + const queryClient = useQueryClient(); const { entityFqn: topicFQN } = useFqn({ type: EntityType.TOPIC }); - const [topicDetails, setTopicDetails] = useState({} as Topic); - const [isLoading, setLoading] = useState(true); - const [isError, setIsError] = useState(false); - + const [permissionsLoading, setPermissionsLoading] = useState(true); const [topicPermissions, setTopicPermissions] = useState( DEFAULT_ENTITY_PERMISSION ); - const { id: topicId, version: currentVersion } = topicDetails; + const canViewTopic = useMemo( + () => + getPrioritizedViewPermission( + topicPermissions, + PermissionOperation.ViewBasic + ) === true, + [topicPermissions] + ); - const saveUpdatedTopicData = (updatedData: Topic) => { - const jsonPatch = compare(omitBy(topicDetails, isUndefined), updatedData); + const topicCacheKey = useMemo( + () => topicQueryKey(topicFQN, TOPIC_DEFAULT_FIELDS), + [topicFQN] + ); - return patchTopicDetails(topicId, jsonPatch); - }; + const { + data: topicDetails, + isLoading: topicLoading, + error: topicError, + } = useQuery({ + queryKey: topicCacheKey, + queryFn: topicQueryFn(topicFQN, TOPIC_DEFAULT_FIELDS), + enabled: Boolean(topicFQN && canViewTopic && !permissionsLoading), + }); - const onTopicUpdate = async (updatedData: Topic, key?: keyof Topic) => { - try { - const res = await saveUpdatedTopicData(updatedData); + const isError = useMemo( + () => (topicError as AxiosError | undefined)?.response?.status === 404, + [topicError] + ); - setTopicDetails((previous) => { - return { - ...previous, - ...res, - ...(key && { [key]: res[key] }), - }; - }); - } catch (error) { - showErrorToast(error as AxiosError); + useEffect(() => { + const status = (topicError as AxiosError | undefined)?.response?.status; + if (status === ClientErrors.FORBIDDEN) { + navigate(ROUTES.FORBIDDEN, { replace: true }); + } else if (status && status !== 404) { + showErrorToast( + topicError as AxiosError, + t('server.entity-details-fetch-error', { + entityType: t('label.topic'), + entityName: topicFQN, + }) + ); } - }; + }, [topicError, navigate, topicFQN, t]); + useEffect(() => { + if (!topicDetails) { + return; + } + addToRecentViewed({ + displayName: getEntityName(topicDetails), + entityType: EntityType.TOPIC, + fqn: topicDetails.fullyQualifiedName ?? '', + serviceType: topicDetails.serviceType, + timestamp: 0, + id: topicDetails.id, + }); + }, [topicDetails]); + + const setTopicDetails = useCallback( + ( + updater: + | Topic + | undefined + | ((prev: Topic | undefined) => Topic | undefined) + ) => { + queryClient.setQueryData(topicCacheKey, updater); + }, + [queryClient, topicCacheKey] + ); + + const refetchTopicDetails = useCallback( + () => queryClient.invalidateQueries({ queryKey: topicCacheKey }), + [queryClient, topicCacheKey] + ); + + const { id: topicId, version: currentVersion } = topicDetails ?? {}; + const isFollowing = useMemo( + () => topicDetails?.followers?.some(({ id }) => id === USERId) ?? false, + [topicDetails?.followers, USERId] + ); + const entityName = useMemo(() => getEntityName(topicDetails), [topicDetails]); + + // See DashboardDetailsPage for the rationale on NOT using useCallback here. const fetchResourcePermission = async (entityFqn: string) => { - setLoading(true); + setPermissionsLoading(true); try { const permissions = await getEntityPermissionByFqn( ResourceEntity.TOPIC, @@ -105,99 +173,114 @@ const TopicDetailsPage: FunctionComponent = () => { setTopicPermissions(permissions); } catch { showErrorToast( - t('server.fetch-entity-permissions-error', { - entity: entityFqn, - }) + t('server.fetch-entity-permissions-error', { entity: entityFqn }) ); } finally { - setLoading(false); + setPermissionsLoading(false); } }; - const fetchTopicDetail = async (topicFQN: string) => { - setLoading(true); - try { - const res = await getTopicByFqn(topicFQN, { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.FOLLOWERS, - TabSpecificField.TAGS, - TabSpecificField.DOMAINS, - TabSpecificField.DATA_PRODUCTS, - TabSpecificField.VOTES, - TabSpecificField.EXTENSION, - ].join(','), - }); - const { id, fullyQualifiedName, serviceType } = res; + const saveUpdatedTopicData = useCallback( + (updatedData: Topic) => { + if (!topicDetails || !topicId) { + return Promise.reject(new Error('Topic not loaded')); + } + const jsonPatch = compare(omitBy(topicDetails, isUndefined), updatedData); - setTopicDetails(res); + return patchTopicDetails(topicId, jsonPatch); + }, + [topicDetails, topicId] + ); + + const onTopicUpdate = async (updatedData: Topic, key?: keyof Topic) => { + try { + const res = await saveUpdatedTopicData(updatedData); + setTopicDetails((previous) => { + if (!previous) { + return previous; + } - addToRecentViewed({ - displayName: getEntityName(res), - entityType: EntityType.TOPIC, - fqn: fullyQualifiedName ?? '', - serviceType: serviceType, - timestamp: 0, - id: id, + return { + ...previous, + ...res, + ...(key && { [key]: res[key] }), + }; }); } catch (error) { - if ((error as AxiosError).response?.status === 404) { - setIsError(true); - } else if ( - (error as AxiosError)?.response?.status === ClientErrors.FORBIDDEN - ) { - navigate(ROUTES.FORBIDDEN, { replace: true }); - } else { - showErrorToast( - error as AxiosError, - t('server.entity-details-fetch-error', { - entityType: t('label.pipeline'), - entityName: topicFQN, - }) - ); - } - } finally { - setLoading(false); + showErrorToast(error as AxiosError); } }; - const followTopic = async () => { - try { - const res = await addFollower(topicId, USERId); - const { newValue } = res.changeDescription.fieldsAdded[0]; - setTopicDetails((prev) => ({ - ...prev, - followers: [...(prev?.followers ?? []), ...newValue], - })); - } catch (error) { - showErrorToast( - error as AxiosError, - t('server.entity-follow-error', { - entity: getEntityName(topicDetails), - }) + const followMutation = useMutation< + void, + AxiosError, + void, + { previous: Topic | undefined } + >({ + mutationFn: async () => { + if (!topicId) { + return; + } + if (isFollowing) { + await removeFollower(topicId, USERId); + } else { + await addFollower(topicId, USERId); + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: topicCacheKey }); + const previous = queryClient.getQueryData( + topicCacheKey ); - } - }; + queryClient.setQueryData(topicCacheKey, (prev) => { + if (!prev) { + return prev; + } + const currentFollowers = prev.followers ?? []; + if (isFollowing) { + return { + ...prev, + followers: currentFollowers.filter(({ id }) => id !== USERId), + }; + } - const unFollowTopic = async () => { - try { - const res = await removeFollower(topicId, USERId); - const { oldValue } = res.changeDescription.fieldsDeleted[0]; - setTopicDetails((prev) => ({ - ...prev, - followers: (prev?.followers ?? []).filter( - (follower) => follower.id !== oldValue[0].id - ), - })); - } catch (error) { + return { + ...prev, + followers: [ + ...currentFollowers, + { id: USERId, type: 'user' }, + ] as Topic['followers'], + }; + }); + + return { previous }; + }, + onError: (error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData( + topicCacheKey, + context.previous + ); + } showErrorToast( error as AxiosError, - t('server.entity-unfollow-error', { - entity: getEntityName(topicDetails), - }) + isFollowing + ? t('server.entity-unfollow-error', { entity: entityName }) + : t('server.entity-follow-error', { entity: entityName }) ); - } - }; + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: topicCacheKey }); + }, + }); + + const followTopic = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); + + const unFollowTopic = useCallback(async () => { + await followMutation.mutateAsync(); + }, [followMutation]); const versionHandler = () => { currentVersion && @@ -223,28 +306,22 @@ const TopicDetailsPage: FunctionComponent = () => { const updateVote = async (data: QueryVote, id: string) => { try { await updateTopicVotes(id, data); - const details = await getTopicByFqn(topicFQN, { - fields: [ - TabSpecificField.OWNERS, - TabSpecificField.FOLLOWERS, - TabSpecificField.TAGS, - TabSpecificField.VOTES, - ].join(','), - }); - setTopicDetails(details); + await queryClient.invalidateQueries({ queryKey: topicCacheKey }); } catch (error) { showErrorToast(error as AxiosError); } }; - const updateTopicDetailsState = useCallback((data: DataAssetWithDomains) => { - const updatedData = data as Topic; - - setTopicDetails((data) => ({ - ...(updatedData ?? data), - version: updatedData.version, - })); - }, []); + const updateTopicDetailsState = useCallback( + (data: DataAssetWithDomains) => { + const updatedData = data as Topic; + setTopicDetails((prev) => ({ + ...(updatedData ?? prev), + version: updatedData.version, + })); + }, + [setTopicDetails] + ); useEffect(() => { if (topicFQN) { @@ -252,18 +329,7 @@ const TopicDetailsPage: FunctionComponent = () => { } }, [topicFQN]); - useEffect(() => { - if ( - getPrioritizedViewPermission( - topicPermissions, - PermissionOperation.ViewBasic - ) - ) { - fetchTopicDetail(topicFQN); - } - }, [topicPermissions, topicFQN]); - - if (isLoading) { + if (permissionsLoading || topicLoading) { return ; } if (isError) { @@ -284,10 +350,13 @@ const TopicDetailsPage: FunctionComponent = () => { /> ); } + if (!topicDetails) { + return ; + } return ( fetchTopicDetail(topicFQN)} + fetchTopic={refetchTopicDetails} followTopicHandler={followTopic} handleToggleDelete={handleToggleDelete} topicDetails={topicDetails} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.test.tsx index 647691afc3e1..f5df70abda8e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TopicDetails/TopicDetailsPage.test.tsx @@ -11,9 +11,9 @@ * limitations under the License. */ -import { findByText, render, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { screen, waitFor } from '@testing-library/react'; import { getTopicByFqn } from '../../rest/topicsAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import TopicDetailsPageComponent from './TopicDetailsPage.component'; jest.mock('../../components/Topic/TopicDetails/TopicDetails.component', () => { @@ -48,7 +48,7 @@ jest.mock('../../hooks/useFqn', () => ({ jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ usePermissionProvider: jest.fn().mockImplementation(() => ({ permissions: {}, - getEntityPermission: jest.fn().mockResolvedValue({ + getEntityPermissionByFqn: jest.fn().mockResolvedValue({ Create: true, Delete: true, EditAll: true, @@ -106,16 +106,11 @@ describe('Test TopicDetailsPage component', () => { }); it('TopicDetailsPage component should render properly', async () => { - const { container } = render(, { - wrapper: MemoryRouter, - }); + renderWithQueryClient(); - const topicDetailComponent = await findByText( - container, - /TopicDetails.component/i + await waitFor(() => + expect(screen.getByText(/TopicDetails.component/i)).toBeInTheDocument() ); - - expect(topicDetailComponent).toBeInTheDocument(); }); it('Should extract topic FQN from field-level deep link URL', async () => { @@ -129,15 +124,13 @@ describe('Test TopicDetailsPage component', () => { }); }); - render(, { - wrapper: MemoryRouter, - }); + renderWithQueryClient(); - await waitFor(() => { + await waitFor(() => expect(getTopicByFqn).toHaveBeenCalledWith( 'sample_kafka.sales', expect.any(Object) - ); - }); + ) + ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/queryClient.ts b/openmetadata-ui/src/main/resources/ui/src/queryClient.ts new file mode 100644 index 000000000000..088dd9e5a1d8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/queryClient.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; + +/** + * Singleton {@link QueryClient} used by the app. Exporting the instance directly (in addition + * to providing it via {@code QueryClientProvider}) lets non-hook callers — axios interceptors, + * AuthProvider's logout handler, anywhere outside React — invalidate and prefetch without + * threading a ref through the tree. + * + * Defaults are tuned for OpenMetadata's typical access pattern. {@code staleTime: 0} + * implements stale-while-revalidate: cached data renders synchronously on mount (so a + * hover-prefetch hit still feels instant), AND a background refetch fires to verify the + * value is current. This restores the network behavior that pre-migration Playwright tests + * assert on (they do {@code page.reload()} → wait for the entity GET to fire), without + * losing the perceived-instant feel of the cache. The refetch hits the backend's Caffeine + * cache so it's sub-ms on the server side. + * + * {@code gcTime} (formerly {@code cacheTime}) stays at 30 minutes so back-navigation + * within the session still benefits from the cached body during the refetch window. + * + * Mutations don't retry — a failed PUT shouldn't replay silently on a flaky network. + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + gcTime: 30 * 60 * 1000, + // Default refetch-on-focus to false: OpenMetadata entities don't change second-by-second + // and many users alt-tab a lot during editing. Pages that need fresh-on-focus opt in + // explicitly via {@code refetchOnWindowFocus: true} on the individual query. + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + // Don't retry on 4xx — typically a permission or not-found error that won't change on + // retry. Server-error 5xx retries up to 2 times with React Query's default backoff. + const status = (error as { response?: { status?: number } })?.response + ?.status; + if (status !== undefined && status >= 400 && status < 500) { + return false; + } + + return failureCount < 2; + }, + }, + mutations: { + // Mutations stay one-shot — a failed PUT shouldn't replay silently. Each call site + // handles error UI explicitly. + retry: false, + }, + }, +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts new file mode 100644 index 000000000000..79e3bd7ac0b2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts @@ -0,0 +1,260 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AxiosHeaders, + AxiosInstance, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; +import Qs from 'qs'; + +/** + * Client-side ETag / If-None-Match handling for entity GETs. + * + * Pairs with the server-side ETagResponseFilter which emits ETag + Cache-Control: no-store on + * entity GET responses and short-circuits to 304 when If-None-Match matches. The server uses + * no-store specifically so the browser doesn't HTTP-cache and serve stale bodies on a 304 from + * mutation paths that don't bump the entity version (addFollower/updateVote/etc.); all + * conditional-GET logic lives here in the in-memory cache, which we invalidate on every + * non-GET response. The flow: + * + * 1. First GET to /tables/{fqn} → response with ETag header. We cache (etag, body) keyed + * by the canonical URL+params. + * 2. Second GET to the same URL → we attach If-None-Match. Server compares against the + * current ETag. + * - Match → 304 with empty body. Interceptor returns cached body as if it were 200. + * - No match → 200 with fresh body. Interceptor refreshes the cached entry. + * + * Wins: zero body bytes on the wire on revisit, plus skip JSON parse + render. + * + * Bounded memory: simple LRU cap at MAX_ENTRIES; oldest evicted on overflow. A typical entity + * GET response is 5-50 KB so the cap holds the cache to ~10 MB worst case. + */ + +interface CachedEntry { + etag: string; + data: unknown; +} + +const MAX_ENTRIES = 200; + +// Map preserves insertion order — re-set on hit to keep recently-used entries at the back. +const etagCache = new Map(); + +function buildKey(config: InternalAxiosRequestConfig): string { + const method = (config.method ?? 'get').toUpperCase(); + const url = config.url ?? ''; + const params = config.params + ? `?${Qs.stringify(config.params, { arrayFormat: 'comma' })}` + : ''; + + return `${method} ${url}${params}`; +} + +function touch(key: string, entry: CachedEntry): void { + etagCache.delete(key); + etagCache.set(key, entry); + + if (etagCache.size > MAX_ENTRIES) { + const oldest = etagCache.keys().next().value; + if (oldest !== undefined) { + etagCache.delete(oldest); + } + } +} + +function readEtagHeader(response: AxiosResponse): string | undefined { + const headers = response.headers; + if (!headers) { + return undefined; + } + + if (headers instanceof AxiosHeaders) { + const v = headers.get('etag'); + + return typeof v === 'string' ? v : undefined; + } + + const candidate = (headers as Record).etag; + if (typeof candidate === 'string') { + return candidate; + } + const candidateUpper = (headers as Record).ETag; + + return typeof candidateUpper === 'string' ? candidateUpper : undefined; +} + +// Marker we stamp on an AxiosInstance once we've installed our interceptor pair. Lets the +// function be properly idempotent — re-invocation (HMR, test setup re-runs, callers that +// accidentally re-init) is a no-op rather than stacking another interceptor pair plus another +// `validateStatus` override on top of itself. +const ETAG_INTERCEPTOR_INSTALLED = Symbol.for( + '@openmetadata/etag-interceptor-installed' +); + +// Per-request stash slot for the cached body that backs the `If-None-Match` header we +// attached. The response interceptor consults this on a 304 response so we can still +// hand the caller a 200 even if the global Map entry was evicted (LRU overflow) or +// wiped (concurrent mutation → cache clear) between the request firing and the +// response arriving. Without this, a concurrent POST that clears the cache while a +// GET is in flight would surface as a 304 with `response.data === undefined`, which +// the caller has every right to treat as a runtime error. +const ETAG_REQUEST_SNAPSHOT = '__etagSnapshot' as const; +type ConfigWithSnapshot = InternalAxiosRequestConfig & { + [ETAG_REQUEST_SNAPSHOT]?: CachedEntry; +}; + +/** + * Wire ETag handling into the axios client. Idempotent — calling twice on the same client is + * a no-op (guarded via a symbol marker on the instance). Callers that re-init axios from + * scratch should also clear the cache via {@link clearEtagCache}. + */ +export function attachEtagInterceptor(client: AxiosInstance): void { + const marker = client as unknown as Record; + if (marker[ETAG_INTERCEPTOR_INSTALLED]) { + return; + } + marker[ETAG_INTERCEPTOR_INSTALLED] = true; + + // Treat 304 as a success status so axios delivers it through the response interceptor + // instead of the error path. Without this, our 304-handling code would have to live in + // the error interceptor and intercepts on every error path. + const previousValidate = client.defaults.validateStatus; + client.defaults.validateStatus = (status: number) => + status === 304 || + (previousValidate + ? previousValidate(status) + : status >= 200 && status < 300); + + client.interceptors.request.use((config) => { + const method = (config.method ?? 'get').toLowerCase(); + if (method !== 'get') { + return config; + } + + const entry = etagCache.get(buildKey(config)); + if (!entry) { + return config; + } + + // Axios 1.x always populates config.headers with AxiosHeaders instance, but be + // defensive in case an upstream interceptor swapped it for a plain object. + if (config.headers instanceof AxiosHeaders) { + config.headers.set('If-None-Match', entry.etag); + } else if (config.headers) { + (config.headers as Record)['If-None-Match'] = entry.etag; + } else { + config.headers = new AxiosHeaders({ 'If-None-Match': entry.etag }); + } + + // Stash a reference to the cached entry on the config. Safe to share by reference + // because the entry's `data` is the immutable snapshot we wrote at cache-write time + // (see the 200-handling branch below); we only ever hand consumers `structuredClone` + // copies of it. + (config as ConfigWithSnapshot)[ETAG_REQUEST_SNAPSHOT] = entry; + + return config; + }); + + client.interceptors.response.use((response) => { + const method = (response.config.method ?? 'get').toLowerCase(); + + // Any successful non-GET response is a mutation — wipe the entire ETag cache so the next + // read on any URL fetches fresh state. We have to be aggressive here because several + // server endpoints mutate an entity without bumping its {@code version}/{@code updatedAt} + // (e.g. {@code addFollower}, {@code updateVote}, {@code DataContractRepository.updateLatestResult}), + // so the entity's ETag is unchanged after the mutation and a targeted invalidation by URL + // wouldn't help — the server would still return 304 with our stale cached body. Clearing + // the cache means the next GET goes out without {@code If-None-Match} and the server + // returns 200 with the current body. The cost is small (per-session in-memory map, 200 + // entries max) and avoids correctness bugs in tests and in production. + if (method !== 'get') { + etagCache.clear(); + + return response; + } + + const key = buildKey(response.config); + + if (response.status === 304) { + // Prefer the live cache entry (re-populating LRU recency) but fall back to the + // per-request snapshot stashed at request time. The snapshot covers two races: + // (1) LRU eviction pushed the entry out between request and response, and + // (2) a concurrent mutation (or logout) cleared the entire cache. Either way the + // cached body is still on `response.config` and we can hand it back as a 200. + const liveEntry = etagCache.get(key); + const entry = + liveEntry ?? + (response.config as ConfigWithSnapshot)[ETAG_REQUEST_SNAPSHOT]; + if (entry) { + // Only touch (re-insert) when the entry came from the live cache. If we're using + // the snapshot fallback the cache was intentionally cleared (mutation invalidation + // or logout), and re-inserting would resurrect a stale entry — the next GET for + // the same URL would hit it, attach If-None-Match, get 304 from a server that may + // have changed state in a non-version-bumping way (followers/votes), and serve the + // same stale body again. Returning the snapshot one-shot is fine; persisting it + // is not. + if (liveEntry) { + touch(key, entry); + } + + // Deep-clone the cached body before handing it back. Consumers (UI components, + // utilities, edit handlers) sometimes mutate the entity object they receive — adding + // local UI state, normalising fields, stripping properties — and a shared reference + // would let those mutations leak back into the cache. We also clone on cache write + // (see the 200 branch), so this read-side clone is a defense-in-depth measure in + // case a future caller hands us back the same reference we stored. + return { + ...response, + status: 200, + statusText: 'OK (from ETag cache)', + data: structuredClone(entry.data), + }; + } + + // 304 with no cached body and no stashed snapshot — would only happen if the request + // interceptor didn't run (e.g. caller built the If-None-Match header directly). Bubble + // through; better than fabricating a fake 200. + return response; + } + + if (response.status === 200) { + const etag = readEtagHeader(response); + if (etag && response.data !== undefined) { + // Clone on write so the cached entry is decoupled from the live response object the + // caller is about to consume. Without this, a caller that mutates `response.data` + // (common pattern: stamping UI-local fields onto an entity) would corrupt the cache, + // and the next 304 would hand the next caller a clone of the already-mutated object. + // structuredClone is sub-millisecond for typical OpenMetadata entities (5-50 KB JSON) + // and is available in all supported browsers (Chrome 98+, Firefox 94+, Safari 15.4+). + touch(key, { etag, data: structuredClone(response.data) }); + } + } + + return response; + }); +} + +/** + * Drop every cached ETag entry. Call on logout / user switch so a freshly-authenticated user + * never receives another user's cached body via 304. + */ +export function clearEtagCache(): void { + etagCache.clear(); +} + +/** Test/debug helper. Returns the count of entries currently held. */ +export function etagCacheSize(): number { + return etagCache.size; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/index.ts b/openmetadata-ui/src/main/resources/ui/src/rest/index.ts index fd795dc40623..4110647d3577 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/index.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/index.ts @@ -14,10 +14,17 @@ import axios from 'axios'; import Qs from 'qs'; import { getBasePath } from '../utils/HistoryUtils'; +import { attachEtagInterceptor } from './etagInterceptor'; const axiosClient = axios.create({ baseURL: `${getBasePath()}/api/v1`, paramsSerializer: (params) => Qs.stringify(params, { arrayFormat: 'comma' }), }); +// Client-side If-None-Match support paired with the server's ETagResponseFilter. Saves the +// response body bytes + a JSON parse + a render on entity GET revisits within a session. +// Attached here (before AuthProvider's interceptors) so it sits closest to the wire and +// every other interceptor sees the resolved 304→200-with-cached-body translation. +attachEtagInterceptor(axiosClient); + export default axiosClient; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/apiCollectionQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/apiCollectionQuery.ts new file mode 100644 index 000000000000..44dd31df4a3f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/apiCollectionQuery.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { APICollection } from '../../generated/entity/data/apiCollection'; +import { Include } from '../../generated/type/include'; +import { getApiCollectionByFQN } from '../apiCollectionsAPI'; + +// Field list the detail page reads on mount. Inlined here (not imported from a Utils file) +// to avoid the kind of circular-import problem that bit DashboardDetailsUtils (see +// {@code dashboardQuery.ts} for the write-up). Keep in sync with the explicit list used +// in {@code APICollectionPage}. +export const API_COLLECTION_DEFAULT_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.TAGS, + TabSpecificField.DOMAINS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, + TabSpecificField.DATA_PRODUCTS, +].join(','); + +/** + * Shared query plumbing for a single API Collection by FQN. Mirrors {@code tableQuery.ts} — + * see that file for the rationale behind keying on {@code (fqn, fields)} and the + * prefetch / cache-hit story. + * + * Unlike other detail pages, API Collections always pass {@code include: Include.All} so + * the page can render both live and soft-deleted collections without an extra round-trip. + * Bake the include into the query function so callers don't have to remember. + */ +export const apiCollectionQueryKey = (fqn: string, fields: string) => + ['apiCollection', fqn, fields] as const; + +export const apiCollectionQueryFn = (fqn: string, fields: string) => () => + getApiCollectionByFQN(fqn, { fields, include: Include.All }); + +export const prefetchApiCollectionByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: apiCollectionQueryKey(fqn, fields), + queryFn: apiCollectionQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type ApiCollectionQueryData = APICollection | undefined; + +export const prefetchApiCollection = (queryClient: QueryClient, fqn: string) => + prefetchApiCollectionByFqn(queryClient, fqn, API_COLLECTION_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/apiEndpointQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/apiEndpointQuery.ts new file mode 100644 index 000000000000..0c1660090a56 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/apiEndpointQuery.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { APIEndpoint } from '../../generated/entity/data/apiEndpoint'; +import { getApiEndPointByFQN } from '../apiEndpointsAPI'; + +// Field list the detail page reads on mount. Mirrors the explicit list in +// {@code APIEndpointPage} (kept inline there rather than in a Utils file). +export const API_ENDPOINT_DEFAULT_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.DOMAINS, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, +].join(','); + +/** + * Shared query plumbing for a single API Endpoint by FQN. Mirrors {@code tableQuery.ts} — + * see that file for the rationale behind keying on {@code (fqn, fields)} and the + * prefetch / cache-hit story. + */ +export const apiEndpointQueryKey = (fqn: string, fields: string) => + ['apiEndpoint', fqn, fields] as const; + +export const apiEndpointQueryFn = (fqn: string, fields: string) => () => + getApiEndPointByFQN(fqn, { fields }); + +export const prefetchApiEndpointByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: apiEndpointQueryKey(fqn, fields), + queryFn: apiEndpointQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type ApiEndpointQueryData = APIEndpoint | undefined; + +export const prefetchApiEndpoint = (queryClient: QueryClient, fqn: string) => + prefetchApiEndpointByFqn(queryClient, fqn, API_ENDPOINT_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/chartQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/chartQuery.ts new file mode 100644 index 000000000000..76a17db4b329 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/chartQuery.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Chart } from '../../generated/entity/data/chart'; +import { getChartByFqn } from '../chartsAPI'; + +// Inlined here (not imported from {@code ChartDetailsUtils}) to avoid the kind of circular +// import that broke production bundles for Dashboard (see {@code dashboardQuery.ts} for the +// detailed write-up). Keep this list in sync with {@code ChartDetailsUtils.defaultFields}. +const CHART_DEFAULT_FIELDS = [ + TabSpecificField.DOMAINS, + TabSpecificField.OWNERS, + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.VOTES, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.EXTENSION, +].join(','); + +/** + * Shared query plumbing for a single Chart entity by FQN. Mirrors {@code tableQuery.ts} — + * see that file for the rationale behind keying on {@code (fqn, fields)} and the + * prefetch / cache-hit story. + */ +export const chartQueryKey = (fqn: string, fields: string) => + ['chart', fqn, fields] as const; + +export const chartQueryFn = (fqn: string, fields: string) => () => + getChartByFqn(fqn, { fields }); + +export const prefetchChartByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: chartQueryKey(fqn, fields), + queryFn: chartQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type ChartQueryData = Chart | undefined; + +const PREFETCH_CHART_FIELDS = `${CHART_DEFAULT_FIELDS},${TabSpecificField.USAGE_SUMMARY}`; + +export const prefetchChart = (queryClient: QueryClient, fqn: string) => + prefetchChartByFqn(queryClient, fqn, PREFETCH_CHART_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/containerQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/containerQuery.ts new file mode 100644 index 000000000000..c05b82cda8f0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/containerQuery.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Container } from '../../generated/entity/data/container'; +import { Include } from '../../generated/type/include'; +import { getContainerByName } from '../storageAPI'; + +// {@code ContainerPage} historically passes {@code fields} as a {@code string[]} rather than +// a comma-joined string — Axios serializes either form to repeated query params, but the +// existing component test asserts the array shape. Keep the array so the migration is a +// no-op for snapshot/argument matchers. +export const CONTAINER_DEFAULT_FIELDS: string[] = [ + TabSpecificField.PARENT, + TabSpecificField.DATAMODEL, + TabSpecificField.OWNERS, + TabSpecificField.TAGS, + TabSpecificField.FOLLOWERS, + TabSpecificField.EXTENSION, + TabSpecificField.DOMAINS, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.VOTES, +]; + +export const containerQueryKey = (fqn: string, fields: string | string[]) => + [ + 'container', + fqn, + Array.isArray(fields) ? fields.join(',') : fields, + ] as const; + +export const containerQueryFn = + (fqn: string, fields: string | string[]) => () => + getContainerByName(fqn, { fields, include: Include.All }); + +export const prefetchContainerByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string | string[] +) => + queryClient + .prefetchQuery({ + queryKey: containerQueryKey(fqn, fields), + queryFn: containerQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type ContainerQueryData = Container | undefined; + +export const prefetchContainer = (queryClient: QueryClient, fqn: string) => + prefetchContainerByFqn(queryClient, fqn, CONTAINER_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardDataModelQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardDataModelQuery.ts new file mode 100644 index 000000000000..a96d76cee832 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardDataModelQuery.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { DashboardDataModel } from '../../generated/entity/data/dashboardDataModel'; +import { Include } from '../../generated/type/include'; +import { getDataModelByFqn } from '../dataModelsAPI'; + +// Inlined here (not imported from {@code DataModelsUtils}) to avoid the kind of +// circular import that broke production bundles for Dashboard (see +// {@code dashboardQuery.ts} for the detailed write-up). Keep this list in sync +// with the fields read by {@code DataModelPage.component}. +const DASHBOARD_DATA_MODEL_DEFAULT_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.TAGS, + TabSpecificField.FOLLOWERS, + TabSpecificField.VOTES, + TabSpecificField.DOMAINS, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.EXTENSION, +].join(','); + +export const dashboardDataModelQueryKey = (fqn: string, fields: string) => + ['dashboardDataModel', fqn, fields] as const; + +export const dashboardDataModelQueryFn = (fqn: string, fields: string) => () => + getDataModelByFqn(fqn, { fields, include: Include.All }); + +export const prefetchDashboardDataModelByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: dashboardDataModelQueryKey(fqn, fields), + queryFn: dashboardDataModelQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type DashboardDataModelQueryData = DashboardDataModel | undefined; + +export const prefetchDashboardDataModel = ( + queryClient: QueryClient, + fqn: string +) => + prefetchDashboardDataModelByFqn( + queryClient, + fqn, + DASHBOARD_DATA_MODEL_DEFAULT_FIELDS + ); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts new file mode 100644 index 000000000000..8c4cb9070f72 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dashboardQuery.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Dashboard } from '../../generated/entity/data/dashboard'; +import { getDashboardByFqn } from '../dashboardAPI'; + +// Inlined here (not imported from {@code DashboardDetailsUtils}) to break a circular import: +// {@code DashboardDetailsUtils.tsx} pulls {@code ChartType} from +// {@code DashboardDetailsPage.component.tsx}, and the page imports {@code dashboardQueryFn} / +// {@code dashboardQueryKey} from this file. Importing back into Utils from here closes the +// cycle and triggers a TDZ "Cannot access X before initialization" error in production +// bundles. Keep this list in sync with {@code DashboardDetailsUtils.defaultFields}. +const DASHBOARD_DEFAULT_FIELDS = [ + TabSpecificField.DOMAINS, + TabSpecificField.OWNERS, + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.CHARTS, + TabSpecificField.VOTES, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.EXTENSION, +].join(','); + +/** + * Shared query plumbing for a single Dashboard by FQN. Mirrors + * {@code tableQuery.ts} — see that file for the rationale behind keying on + * {@code (fqn, fields)} and the prefetch / cache-hit story. + */ +export const dashboardQueryKey = (fqn: string, fields: string) => + ['dashboard', fqn, fields] as const; + +export const dashboardQueryFn = (fqn: string, fields: string) => () => + getDashboardByFqn(fqn, { fields }); + +export const prefetchDashboardByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: dashboardQueryKey(fqn, fields), + queryFn: dashboardQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type DashboardQueryData = Dashboard | undefined; + +const PREFETCH_DASHBOARD_FIELDS = `${DASHBOARD_DEFAULT_FIELDS},${TabSpecificField.USAGE_SUMMARY}`; + +/** + * Convenience wrapper for hover handlers — uses the maximal fields the + * detail page reads when the viewer has {@code ViewUsage} permission. + */ +export const prefetchDashboard = (queryClient: QueryClient, fqn: string) => + prefetchDashboardByFqn(queryClient, fqn, PREFETCH_DASHBOARD_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/dataProductQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dataProductQuery.ts new file mode 100644 index 000000000000..feb5def4e84e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/dataProductQuery.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { DataProduct } from '../../generated/entity/domains/dataProduct'; +import { getDataProductByName } from '../dataProductAPI'; + +export const DATA_PRODUCT_DEFAULT_FIELDS: TabSpecificField[] = [ + TabSpecificField.DOMAINS, + TabSpecificField.OWNERS, + TabSpecificField.EXPERTS, + TabSpecificField.ASSETS, + TabSpecificField.EXTENSION, + TabSpecificField.TAGS, + TabSpecificField.FOLLOWERS, + TabSpecificField.REVIEWERS, + TabSpecificField.VOTES, + TabSpecificField.CERTIFICATION, +]; + +export const dataProductQueryKey = (fqn: string, fields: string[]) => + ['dataProduct', fqn, fields.join(',')] as const; + +export const dataProductQueryFn = (fqn: string, fields: string[]) => () => + getDataProductByName(fqn, { fields }); + +export const prefetchDataProductByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string[] +) => + queryClient + .prefetchQuery({ + queryKey: dataProductQueryKey(fqn, fields), + queryFn: dataProductQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type DataProductQueryData = DataProduct | undefined; + +export const prefetchDataProduct = (queryClient: QueryClient, fqn: string) => + prefetchDataProductByFqn(queryClient, fqn, DATA_PRODUCT_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/databaseQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/databaseQuery.ts new file mode 100644 index 000000000000..7ec1789f4bfe --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/databaseQuery.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Database } from '../../generated/entity/data/database'; +import { Include } from '../../generated/type/include'; +import { getDatabaseDetailsByFQN } from '../databaseAPI'; + +export const DATABASE_DEFAULT_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.TAGS, + TabSpecificField.DOMAINS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.FOLLOWERS, +].join(','); + +export const databaseQueryKey = (fqn: string, fields: string) => + ['database', fqn, fields] as const; + +export const databaseQueryFn = (fqn: string, fields: string) => () => + getDatabaseDetailsByFQN(fqn, { fields, include: Include.All }); + +export const prefetchDatabaseByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: databaseQueryKey(fqn, fields), + queryFn: databaseQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type DatabaseQueryData = Database | undefined; + +export const prefetchDatabase = (queryClient: QueryClient, fqn: string) => + prefetchDatabaseByFqn(queryClient, fqn, DATABASE_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/databaseSchemaQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/databaseSchemaQuery.ts new file mode 100644 index 000000000000..5656655932cb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/databaseSchemaQuery.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { DatabaseSchema } from '../../generated/entity/data/databaseSchema'; +import { Include } from '../../generated/type/include'; +import { getDatabaseSchemaDetailsByFQN } from '../databaseAPI'; + +export const DATABASE_SCHEMA_DEFAULT_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.TAGS, + TabSpecificField.DOMAINS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, + TabSpecificField.FOLLOWERS, + TabSpecificField.DATA_PRODUCTS, +].join(','); + +export const databaseSchemaQueryKey = (fqn: string, fields: string) => + ['databaseSchema', fqn, fields] as const; + +export const databaseSchemaQueryFn = (fqn: string, fields: string) => () => + getDatabaseSchemaDetailsByFQN(fqn, { fields, include: Include.All }); + +export const prefetchDatabaseSchemaByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: databaseSchemaQueryKey(fqn, fields), + queryFn: databaseSchemaQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type DatabaseSchemaQueryData = DatabaseSchema | undefined; + +const PREFETCH_DATABASE_SCHEMA_FIELDS = `${DATABASE_SCHEMA_DEFAULT_FIELDS},${TabSpecificField.USAGE_SUMMARY}`; + +export const prefetchDatabaseSchema = (queryClient: QueryClient, fqn: string) => + prefetchDatabaseSchemaByFqn( + queryClient, + fqn, + PREFETCH_DATABASE_SCHEMA_FIELDS + ); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/domainQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/domainQuery.ts new file mode 100644 index 000000000000..8f055f5063c7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/domainQuery.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Domain } from '../../generated/entity/domains/domain'; +import { getDomainByName } from '../domainAPI'; + +export const DOMAIN_DEFAULT_FIELDS: TabSpecificField[] = [ + TabSpecificField.CHILDREN, + TabSpecificField.OWNERS, + TabSpecificField.PARENT, + TabSpecificField.EXPERTS, + TabSpecificField.TAGS, + TabSpecificField.FOLLOWERS, + TabSpecificField.EXTENSION, + TabSpecificField.VOTES, + TabSpecificField.CERTIFICATION, +]; + +export const domainQueryKey = (fqn: string, fields: string[]) => + ['domain', fqn, fields.join(',')] as const; + +export const domainQueryFn = (fqn: string, fields: string[]) => () => + getDomainByName(fqn, { fields }); + +export const prefetchDomainByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string[] +) => + queryClient + .prefetchQuery({ + queryKey: domainQueryKey(fqn, fields), + queryFn: domainQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type DomainQueryData = Domain | undefined; + +export const prefetchDomain = (queryClient: QueryClient, fqn: string) => + prefetchDomainByFqn(queryClient, fqn, DOMAIN_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/glossaryQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/glossaryQuery.ts new file mode 100644 index 000000000000..6c50e2d66909 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/glossaryQuery.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Glossary } from '../../generated/entity/data/glossary'; +import { getGlossariesByName } from '../glossaryAPI'; + +/** + * Field set used when a single root {@code Glossary} entity is fetched by FQN. The + * {@code GlossaryPage} list view pulls glossaries through {@code getGlossariesList} (because + * the left panel needs the full set), but consumers that want a single-glossary cache slot + * — hover prefetch, deep links, the upcoming {@code GlossaryDetails} mount path — should go + * through this helper so they land on the canonical key shape. + */ +export const GLOSSARY_DEFAULT_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.TAGS, + TabSpecificField.REVIEWERS, + TabSpecificField.VOTES, + TabSpecificField.DOMAINS, + TabSpecificField.TERM_COUNT, +].join(','); + +export const glossaryQueryKey = (fqn: string, fields: string) => + ['glossary', fqn, fields] as const; + +export const glossaryQueryFn = (fqn: string, fields: string) => () => + getGlossariesByName(fqn, { fields }); + +export const prefetchGlossaryByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: glossaryQueryKey(fqn, fields), + queryFn: glossaryQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type GlossaryQueryData = Glossary | undefined; + +export const prefetchGlossary = (queryClient: QueryClient, fqn: string) => + prefetchGlossaryByFqn(queryClient, fqn, GLOSSARY_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/glossaryTermQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/glossaryTermQuery.ts new file mode 100644 index 000000000000..d6e6359a1109 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/glossaryTermQuery.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { GlossaryTerm } from '../../generated/entity/data/glossaryTerm'; +import { getGlossaryTermByFQN } from '../glossaryAPI'; + +/** + * Field set the {@code GlossaryPage} reads when the active selection is a glossary term + * (i.e. the URL FQN has more than one segment). Kept in sync with + * {@code fetchGlossaryTermDetails} in {@code GlossaryPage.component.tsx}. + */ +export const GLOSSARY_TERM_DEFAULT_FIELDS = [ + TabSpecificField.RELATED_TERMS, + TabSpecificField.REVIEWERS, + TabSpecificField.TAGS, + TabSpecificField.OWNERS, + TabSpecificField.CHILDREN, + TabSpecificField.VOTES, + TabSpecificField.DOMAINS, + TabSpecificField.EXTENSION, + TabSpecificField.CHILDREN_COUNT, +].join(','); + +export const glossaryTermQueryKey = (fqn: string, fields: string) => + ['glossaryTerm', fqn, fields] as const; + +export const glossaryTermQueryFn = (fqn: string, fields: string) => () => + getGlossaryTermByFQN(fqn, { fields }); + +export const prefetchGlossaryTermByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: glossaryTermQueryKey(fqn, fields), + queryFn: glossaryTermQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type GlossaryTermQueryData = GlossaryTerm | undefined; + +export const prefetchGlossaryTerm = (queryClient: QueryClient, fqn: string) => + prefetchGlossaryTermByFqn(queryClient, fqn, GLOSSARY_TERM_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/incidentManagerQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/incidentManagerQuery.ts new file mode 100644 index 000000000000..0c0b7ceec363 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/incidentManagerQuery.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { TestCase } from '../../generated/tests/testCase'; +import { getTestCaseByFqn } from '../testAPI'; + +// Inlined to avoid a circular import via {@code TestCaseClassBase} — +// the class imports tab components that themselves pull from utils that +// reach back into pages. Keep this list in sync with +// {@code TestCaseClassBase.getFields()}. +export const TEST_CASE_DEFAULT_FIELDS: string[] = [ + TabSpecificField.TESTSUITE, + TabSpecificField.TEST_CASE_RESULT, + TabSpecificField.TEST_DEFINITION, + TabSpecificField.OWNERS, + TabSpecificField.INCIDENT_ID, + TabSpecificField.TAGS, + 'inspectionQuery', +]; + +/** + * Shared query plumbing for a single TestCase (incident) by FQN. The detail + * page hosts this query; child components (TestCaseResultTab, IncidentTab, + * page header) continue to read the same data via the {@code useTestCaseStore} + * Zustand store, which the page mirrors from the React Query cache. + */ +export const testCaseQueryKey = (fqn: string, fields: string[]) => + ['testCase', fqn, fields.join(',')] as const; + +export const testCaseQueryFn = (fqn: string, fields: string[]) => () => + getTestCaseByFqn(fqn, { fields }); + +export const prefetchTestCaseByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string[] +) => + queryClient + .prefetchQuery({ + queryKey: testCaseQueryKey(fqn, fields), + queryFn: testCaseQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type TestCaseQueryData = TestCase | undefined; + +export const prefetchTestCase = (queryClient: QueryClient, fqn: string) => + prefetchTestCaseByFqn(queryClient, fqn, TEST_CASE_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/metricQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/metricQuery.ts new file mode 100644 index 000000000000..38d12b665201 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/metricQuery.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Metric } from '../../generated/entity/data/metric'; +import { getMetricByFqn } from '../metricsAPI'; + +// Field list the detail page reads on mount. Inlined here rather than imported from a +// Utils file to keep the cache-key surface stable across edits to unrelated UI code. +export const METRIC_DEFAULT_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.DOMAINS, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, + TabSpecificField.RELATED_METRICS, + TabSpecificField.REVIEWERS, +].join(','); + +/** + * Shared query plumbing for a single Metric entity by FQN. Mirrors {@code tableQuery.ts} — + * see that file for the rationale behind keying on {@code (fqn, fields)} and the + * prefetch / cache-hit story. + */ +export const metricQueryKey = (fqn: string, fields: string) => + ['metric', fqn, fields] as const; + +export const metricQueryFn = (fqn: string, fields: string) => () => + getMetricByFqn(fqn, { fields }); + +export const prefetchMetricByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: metricQueryKey(fqn, fields), + queryFn: metricQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type MetricQueryData = Metric | undefined; + +export const prefetchMetric = (queryClient: QueryClient, fqn: string) => + prefetchMetricByFqn(queryClient, fqn, METRIC_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/mlModelQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/mlModelQuery.ts new file mode 100644 index 000000000000..273de4400c9d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/mlModelQuery.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Mlmodel } from '../../generated/entity/data/mlmodel'; +import { getMlModelByFQN } from '../mlModelAPI'; + +// Inlined here (not imported from {@code MlModelDetailsUtils}) to avoid the kind of +// circular import that broke production bundles for Dashboard (see {@code dashboardQuery.ts} +// for the detailed write-up). Keep this list in sync with +// {@code MlModelDetailsUtils.defaultFields}. +const MLMODEL_DEFAULT_FIELDS = [ + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.DOMAINS, + TabSpecificField.OWNERS, + TabSpecificField.DASHBOARD, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, +].join(','); + +export const mlModelQueryKey = (fqn: string, fields: string) => + ['mlModel', fqn, fields] as const; + +export const mlModelQueryFn = (fqn: string, fields: string) => () => + getMlModelByFQN(fqn, { fields }); + +export const prefetchMlModelByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: mlModelQueryKey(fqn, fields), + queryFn: mlModelQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type MlModelQueryData = Mlmodel | undefined; + +const PREFETCH_MLMODEL_FIELDS = `${MLMODEL_DEFAULT_FIELDS},${TabSpecificField.USAGE_SUMMARY}`; + +export const prefetchMlModel = (queryClient: QueryClient, fqn: string) => + prefetchMlModelByFqn(queryClient, fqn, PREFETCH_MLMODEL_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts new file mode 100644 index 000000000000..7947185c0b59 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/pipelineQuery.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Pipeline } from '../../generated/entity/data/pipeline'; +import { getPipelineByFqn } from '../pipelineAPI'; + +// Inlined here (not imported from {@code PipelineDetailsUtils}) to avoid the kind of +// circular import that broke production bundles for Dashboard (see {@code dashboardQuery.ts} +// for the detailed write-up). Keep this list in sync with +// {@code PipelineDetailsUtils.defaultFields}. +const PIPELINE_DEFAULT_FIELDS = [ + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.OWNERS, + TabSpecificField.TASKS, + TabSpecificField.PIPELINE_STATUS, + TabSpecificField.DOMAINS, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, +].join(','); + +export const pipelineQueryKey = (fqn: string, fields: string) => + ['pipeline', fqn, fields] as const; + +export const pipelineQueryFn = (fqn: string, fields: string) => () => + getPipelineByFqn(fqn, { fields }); + +export const prefetchPipelineByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: pipelineQueryKey(fqn, fields), + queryFn: pipelineQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type PipelineQueryData = Pipeline | undefined; + +const PREFETCH_PIPELINE_FIELDS = `${PIPELINE_DEFAULT_FIELDS},${TabSpecificField.USAGE_SUMMARY}`; + +export const prefetchPipeline = (queryClient: QueryClient, fqn: string) => + prefetchPipelineByFqn(queryClient, fqn, PREFETCH_PIPELINE_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/searchIndexQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/searchIndexQuery.ts new file mode 100644 index 000000000000..d8b0c956f2de --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/searchIndexQuery.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { SearchIndex } from '../../generated/entity/data/searchIndex'; +import { getSearchIndexDetailsByFQN } from '../SearchIndexAPI'; + +// Inlined here (not imported from {@code SearchIndexUtils}) to avoid the kind of +// circular import that broke production bundles for Dashboard (see {@code dashboardQuery.ts} +// for the detailed write-up). Keep this list in sync with +// {@code SearchIndexUtils.defaultFields}. +export const SEARCH_INDEX_DEFAULT_FIELDS = [ + TabSpecificField.FIELDS, + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.OWNERS, + TabSpecificField.DOMAINS, + TabSpecificField.VOTES, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.EXTENSION, +].join(','); + +export const searchIndexQueryKey = (fqn: string, fields: string) => + ['searchIndex', fqn, fields] as const; + +export const searchIndexQueryFn = (fqn: string, fields: string) => () => + getSearchIndexDetailsByFQN(fqn, { fields }); + +export const prefetchSearchIndexByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: searchIndexQueryKey(fqn, fields), + queryFn: searchIndexQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type SearchIndexQueryData = SearchIndex | undefined; + +export const prefetchSearchIndex = (queryClient: QueryClient, fqn: string) => + prefetchSearchIndexByFqn(queryClient, fqn, SEARCH_INDEX_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/storedProcedureQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/storedProcedureQuery.ts new file mode 100644 index 000000000000..9bd7af31bc71 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/storedProcedureQuery.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { StoredProcedure } from '../../generated/entity/data/storedProcedure'; +import { Include } from '../../generated/type/include'; +import { STORED_PROCEDURE_DEFAULT_FIELDS } from '../../utils/StoredProceduresUtils'; +import { getStoredProceduresByFqn } from '../storedProceduresAPI'; + +export const storedProcedureQueryKey = (fqn: string, fields: string) => + ['storedProcedure', fqn, fields] as const; + +export const storedProcedureQueryFn = (fqn: string, fields: string) => () => + getStoredProceduresByFqn(fqn, { fields, include: Include.All }); + +export const prefetchStoredProcedureByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: storedProcedureQueryKey(fqn, fields), + queryFn: storedProcedureQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type StoredProcedureQueryData = StoredProcedure | undefined; + +export const prefetchStoredProcedure = ( + queryClient: QueryClient, + fqn: string +) => + prefetchStoredProcedureByFqn( + queryClient, + fqn, + STORED_PROCEDURE_DEFAULT_FIELDS + ); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts new file mode 100644 index 000000000000..c1afcd97116a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/tableQuery.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Table } from '../../generated/entity/data/table'; +import { defaultFieldsWithColumns } from '../../utils/DatasetDetailsUtils'; +import { getTableDetailsByFQN } from '../tableAPI'; + +/** + * Shared query plumbing for a single Table entity by FQN. Any consumer that wants a + * cache-aware read — the detail page, a sidebar widget, a hover prefetch — should go + * through {@link tableQueryKey} + {@link tableQueryFn} so they all hit the same normalized + * cache slot. If two callers ask for the same {@code (fqn, fields)} combo, the second + * doesn't re-fire the network request; if a mutation patches the cache via + * {@link QueryClient.setQueryData}, every consumer sees the update. + * + * The {@code fields} list is part of the key on purpose: a "lite" caller that asks for + * fewer fields shouldn't read a "heavy" caller's cached body (it might not contain the + * fields the lite caller's UI actually needs — that's intentional, otherwise stale derived + * state would surface). React Query's {@code structuralSharing} keeps the cost of a wider + * key cheap — overlapping field sets aren't deduped at the cache layer, but the underlying + * HTTP layer's ETag interceptor will translate a duplicate-content request into a 304. + */ +export const tableQueryKey = (fqn: string, fields: string) => + ['table', fqn, fields] as const; + +export const tableQueryFn = (fqn: string, fields: string) => () => + getTableDetailsByFQN(fqn, { fields }); + +/** + * Imperatively populate the cache for {@code fqn} so the next consumer reading the same key + * gets a hit. Use from hover handlers on entity links — by the time the user clicks the + * link and the detail page mounts, the data is already there. Falls through to a normal + * background refetch when the cache entry is stale, so prefetch is idempotent. + * + * Errors are intentionally swallowed: a failed prefetch shouldn't surface a toast (the user + * didn't ask for anything; we predicted they might). The page's own {@code useQuery} will + * surface the same error if it really matters. + */ +export const prefetchTableByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: tableQueryKey(fqn, fields), + queryFn: tableQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type TableQueryData = Table | undefined; + +/** + * Field set used for hover-prefetch. Matches the maximal {@code tableFields} the detail page + * reads when the viewer has both {@code ViewUsage} and {@code ViewTests} permissions — the + * common case for engineering / data users. Restricted viewers (no usage/test perms) read a + * narrower {@code tableFields} on the page, so a hover-prefetch by them lands in a cache slot + * the page won't consume; that's acceptable since prefetch is best-effort and the wasted + * bytes are bounded to one request per hover. + */ +const PREFETCH_TABLE_FIELDS = `${defaultFieldsWithColumns},${TabSpecificField.USAGE_SUMMARY},${TabSpecificField.TESTSUITE}`; + +/** + * Convenience wrapper around {@link prefetchTableByFqn} for hover handlers. Uses the + * canonical {@link PREFETCH_TABLE_FIELDS} so the warmed cache slot matches what + * {@code TableDetailsPageV1} reads on mount for a permitted viewer. + */ +export const prefetchTable = (queryClient: QueryClient, fqn: string) => + prefetchTableByFqn(queryClient, fqn, PREFETCH_TABLE_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/tagQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/tagQuery.ts new file mode 100644 index 000000000000..46ccfc35b6ea --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/tagQuery.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Tag } from '../../generated/entity/classification/tag'; +import { getTagByFqn } from '../tagAPI'; + +export const TAG_DEFAULT_FIELDS: TabSpecificField[] = [ + TabSpecificField.DOMAINS, + TabSpecificField.OWNERS, + TabSpecificField.REVIEWERS, +]; + +export const tagQueryKey = (fqn: string, fields: string[]) => + ['tag', fqn, fields.join(',')] as const; + +export const tagQueryFn = (fqn: string, fields: string[]) => () => + getTagByFqn(fqn, { fields }); + +export const prefetchTagByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string[] +) => + queryClient + .prefetchQuery({ + queryKey: tagQueryKey(fqn, fields), + queryFn: tagQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type TagQueryData = Tag | undefined; + +export const prefetchTag = (queryClient: QueryClient, fqn: string) => + prefetchTagByFqn(queryClient, fqn, TAG_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/queries/topicQuery.ts b/openmetadata-ui/src/main/resources/ui/src/rest/queries/topicQuery.ts new file mode 100644 index 000000000000..6cc5c2f62480 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/queries/topicQuery.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { TabSpecificField } from '../../enums/entity.enum'; +import { Topic } from '../../generated/entity/data/topic'; +import { getTopicByFqn } from '../topicsAPI'; + +export const TOPIC_DEFAULT_FIELDS = [ + TabSpecificField.OWNERS, + TabSpecificField.FOLLOWERS, + TabSpecificField.TAGS, + TabSpecificField.DOMAINS, + TabSpecificField.DATA_PRODUCTS, + TabSpecificField.VOTES, + TabSpecificField.EXTENSION, +].join(','); + +export const topicQueryKey = (fqn: string, fields: string) => + ['topic', fqn, fields] as const; + +export const topicQueryFn = (fqn: string, fields: string) => () => + getTopicByFqn(fqn, { fields }); + +export const prefetchTopicByFqn = ( + queryClient: QueryClient, + fqn: string, + fields: string +) => + queryClient + .prefetchQuery({ + queryKey: topicQueryKey(fqn, fields), + queryFn: topicQueryFn(fqn, fields), + }) + .catch(() => undefined); + +export type TopicQueryData = Topic | undefined; + +export const prefetchTopic = (queryClient: QueryClient, fqn: string) => + prefetchTopicByFqn(queryClient, fqn, TOPIC_DEFAULT_FIELDS); diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/index.ts b/openmetadata-ui/src/main/resources/ui/src/styles/index.ts index b343d01f2c35..10ecf9213569 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/index.ts +++ b/openmetadata-ui/src/main/resources/ui/src/styles/index.ts @@ -17,17 +17,19 @@ import '@fontsource/poppins/500.css'; // Font 500 import '@fontsource/poppins/600.css'; // Font 600 import '@fontsource/source-code-pro'; // Font 400 -import '@fontsource/inter'; // Font 400 -import '@fontsource/inter/400.css'; // Font 400 -import '@fontsource/inter/500.css'; // Font 500 -import '@fontsource/inter/600.css'; // Font 600 -import '@fontsource/inter/700.css'; // Font 700 -import '@fontsource/inter/800.css'; // Font 800 -import '@fontsource/inter/900.css'; // Font 900 +// Variable Inter aliased under the "Inter" family name. Loads one woff2 per +// Unicode subset covering the full 100–900 weight axis, replacing the prior +// 6 weight-specific woff2 files per subset (~30 → ~7 fetches). See the file +// header in {@link ./inter-variable.css} for context. +import './inter-variable.css'; import '@react-awesome-query-builder/antd/css/styles.css'; -import 'reactflow/dist/base.css'; -import 'reactflow/dist/style.css'; +// reactflow CSS is co-located with the runtime in LineageProvider so it only +// loads when the lineage canvas mounts. Previously imported here, which kept +// `vendor-reactflow` in the entry chunk's modulepreload list even after the +// 11 util/hook files were converted to `import type` (see PR-1 of bundle-size +// follow-up). Side-effect CSS imports count as runtime dependencies for +// Rollup's chunk-graph analysis. import './antd-master.less'; import './app.less'; import './components/add-edit-form-steps.less'; diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css b/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css new file mode 100644 index 000000000000..1702ee763685 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/styles/inter-variable.css @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Re-registers Inter Variable under the existing "Inter" family name so the + * codebase's many `font-family: 'Inter', ...` references keep working without + * having to be rewritten. The @fontsource-variable/inter package ships under + * the name "Inter Variable", which would otherwise force us to update every + * Less file. Each @font-face here points at the same variable woff2 files + * that the package would have loaded under "Inter Variable" — Vite resolves + * the package-relative url() through its CSS plugin. + * + * The variable font carries the full weight axis (100–900) in a single woff2 + * per Unicode subset, replacing the 6 weight-specific files per subset that + * @fontsource/inter used to load. ~30 woff2 fetches collapse to ~7 — one per + * subset. font-display:swap matches the old behavior so cold paint still + * shows the system fallback first. + */ + +/* Cyrillic Extended */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-cyrillic-ext-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, + U+FE2E-FE2F; +} + +/* Cyrillic */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-cyrillic-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* Greek Extended */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-greek-ext-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+1F00-1FFF; +} + +/* Greek */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-greek-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, + U+03A3-03FF; +} + +/* Vietnamese */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-vietnamese-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, + U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, + U+1EA0-1EF9, U+20AB; +} + +/* Latin Extended */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-latin-ext-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, + U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* Latin (most common — loaded first by the browser when matching characters + * from this range fall on the page; preload this one in index.html). */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-latin-wght-normal.woff2') + format('woff2-variations'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, + U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/test/unit/test-utils.tsx b/openmetadata-ui/src/main/resources/ui/src/test/unit/test-utils.tsx new file mode 100644 index 000000000000..8c49a74036db --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/test/unit/test-utils.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, RenderOptions, RenderResult } from '@testing-library/react'; +import { ReactElement, ReactNode } from 'react'; + +/** + * Wraps {@link render} with a {@link QueryClientProvider} carrying a fresh, isolated + * {@link QueryClient} per test. Use this for any component that calls a React Query hook + * (useQuery, useMutation, etc.) — without the provider those hooks throw + * "No QueryClient set, use QueryClientProvider to set one". + * + * Each call creates a NEW client so cached data from one test never leaks to another. The + * client is configured to disable retries (faster failure when an intentionally-mocked + * endpoint rejects) and to never refetch on focus/mount (tests don't simulate those events). + */ +export function renderWithQueryClient( + ui: ReactElement, + options?: Omit +): RenderResult & { queryClient: QueryClient } { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { + ...render(ui, { wrapper: Wrapper, ...options }), + queryClient, + }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/APIServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/APIServiceUtils.ts index 9a0c1581c729..4fb033893019 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/APIServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/APIServiceUtils.ts @@ -13,13 +13,31 @@ import { cloneDeep } from 'lodash'; import { COMMON_UI_SCHEMA } from '../constants/ServiceUISchema.constant'; import { APIServiceType } from '../generated/entity/services/apiService'; -import restConnection from '../jsons/connectionSchemas/connections/api/restConnection.json'; -export const getAPIConfig = (type: APIServiceType) => { - let schema = {}; +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const apiSchemaLoaders: Partial> = { + [APIServiceType.REST]: () => + import('../jsons/connectionSchemas/connections/api/restConnection.json'), +}; + +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; + + return maybeDefault ?? (mod as Record); +}; + +export const getAPIConfig = async (type: APIServiceType) => { + const loader = apiSchemaLoaders[type]; + let schema: Record = {}; const uiSchema = { ...COMMON_UI_SCHEMA }; - if (type === APIServiceType.REST) { - schema = restConnection; + + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx index 509f84622bf9..97ab8050b15f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSV.utils.tsx @@ -14,7 +14,7 @@ import { Tooltip, TooltipTrigger } from '@openmetadata/ui-core-components'; import { Typography } from 'antd'; import { isEmpty, isString, isUndefined, startCase } from 'lodash'; import { parse, unparse } from 'papaparse'; -import { Column, RenderCellProps } from 'react-data-grid'; +import type { Column, RenderCellProps } from 'react-data-grid'; import { ReactComponent as SuccessBadgeIcon } from '../..//assets/svg/success-badge.svg'; import { ReactComponent as FailBadgeIcon } from '../../assets/svg/fail-badge.svg'; import { TableTypePropertyValueType } from '../../components/common/CustomPropertyTable/CustomPropertyTable.interface'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.test.tsx index 5177c3f0bdea..b4092fc12c6c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { textEditor } from 'react-data-grid'; +import { lazyTextEditor } from '../../components/common/DataGrid/LazyDataGrid'; import { EntityType } from '../../enums/entity.enum'; import csvUtilsClassBase, { CSVUtilsClassBase } from './CSVUtilsClassBase'; @@ -162,7 +162,7 @@ describe('CSV utils ClassBase', () => { expect(editor).toBeDefined(); }); - it('should return default textEditor for unknown columns', () => { + it('should return default lazyTextEditor for unknown columns', () => { const column = 'unknown'; const editor = csvUtilsClassBase.getEditor( column, @@ -170,7 +170,7 @@ describe('CSV utils ClassBase', () => { multipleOwner ); - expect(editor).toBe(textEditor); + expect(editor).toBe(lazyTextEditor); }); it('should return the editor component for the "description" column', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx index a976e59bd793..5bcac70fb12a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx @@ -14,9 +14,10 @@ import Select, { DefaultOptionType } from 'antd/lib/select'; import { isEmpty, toString } from 'lodash'; import { ReactNode, useRef } from 'react'; -import { RenderEditCellProps, textEditor } from 'react-data-grid'; +import type { RenderEditCellProps } from 'react-data-grid'; import Certification from '../../components/Certification/Certification.component'; import TreeAsyncSelectList from '../../components/common/AsyncSelectList/TreeAsyncSelectList'; +import { lazyTextEditor } from '../../components/common/DataGrid/LazyDataGrid'; import DomainSelectableList from '../../components/common/DomainSelectableList/DomainSelectableList.component'; import { useMultiContainerFocusTrap } from '../../components/common/FocusTrap/FocusTrapWithContainer'; import InlineEdit from '../../components/common/InlineEdit/InlineEdit.component'; @@ -522,7 +523,7 @@ class CSVUtilsClassBase { ); }; default: - return textEditor; + return lazyTextEditor; } } } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CanvasUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CanvasUtils.ts index 232b80cc2ee2..e4745aea2372 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CanvasUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CanvasUtils.ts @@ -10,7 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Edge, Node, Position, Viewport } from 'reactflow'; +import type { Edge, Node, Viewport } from 'reactflow'; +import { Position } from 'reactflow'; import { EntityChildren } from '../components/Entity/EntityLineage/NodeChildren/NodeChildren.interface'; import { LINEAGE_CHILD_ITEMS_PER_PAGE } from '../constants/constants'; import { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx index d4960e1a2fde..02079ac73dec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx @@ -33,7 +33,7 @@ import { RecentlySearchedData, RecentlyViewedData, } from 'Models'; -import { ReactNode } from 'react'; +import { Dispatch, ReactNode, SetStateAction } from 'react'; import Loader from '../components/common/Loader/Loader'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { BASE_COLORS } from '../constants/DataInsight.constants'; @@ -527,6 +527,77 @@ export const getFeedCounts = async ( } }; +/** + * Eager task-count fetch for entity-detail pages. Pair on mount with + * {@link fetchEntityActivityCountInto} — both are cheap (task counts are aggregate; activity + * count uses {@code limit=0} which short-circuits to a server-side {@code COUNT(*)}) so they + * can run side-by-side on the same render. Total count is derived from + * {@code (conversationCount ?? 0) + totalTasksCount} so the merge stays correct whichever + * fetch arrives first. + */ +export const fetchEntityTaskCountsInto = async ( + entityFqn: string, + setFeedCount: Dispatch>, + domain?: string +) => { + try { + const taskCounts = await getTaskCounts({ aboutEntity: entityFqn, domain }); + setFeedCount((prev) => { + const openTaskCount = taskCounts.open ?? 0; + const closedTaskCount = taskCounts.completed ?? 0; + const totalTasksCount = taskCounts.total ?? 0; + + return { + ...prev, + openTaskCount, + closedTaskCount, + totalTasksCount, + totalCount: (prev.conversationCount ?? 0) + totalTasksCount, + }; + }); + } catch (err) { + showErrorToast(err as AxiosError, t('server.entity-feed-fetch-error')); + } +}; + +/** + * Eager activity-count fetch. Pulls only the count (no events) for an entity and updates the + * {@code conversationCount} and {@code totalCount} fields of the page's {@link FeedCounts} + * state. Backed by {@code limit=0} on {@code GET /v1/activity/entity/{type}/name/{fqn}} — + * the server short-circuits to a {@code COUNT(*)} and returns an empty {@code data} array + * plus an accurate {@code paging.total}. Total payload is a few dozen bytes, so this can stay + * eager on mount and the Activity Feed tab badge populates on first paint. + * + *

Historically the badge ran with {@code limit=100} and read {@code data.length}, which + * (a) shipped 100 row JSONs just to count them and (b) silently capped the displayed value at + * 100. The cheap path is both faster and more accurate. + */ +export const fetchEntityActivityCountInto = async ( + entityType: string, + entityFqn: string, + setFeedCount: Dispatch>, + domain?: string +) => { + try { + const activityRes = await getEntityActivityByFqn(entityType, entityFqn, { + days: 30, + limit: 0, + domain, + }); + setFeedCount((prev) => { + const conversationCount = activityRes?.paging?.total ?? 0; + + return { + ...prev, + conversationCount, + totalCount: conversationCount + (prev.totalTasksCount ?? 0), + }; + }); + } catch (err) { + showErrorToast(err as AxiosError, t('server.entity-feed-fetch-error')); + } +}; + export const formatNumberWithComma = (number: number) => { return new Intl.NumberFormat(i18n.language).format(number); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DashboardServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DashboardServiceUtils.ts index 8a1135061852..01e51881671a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DashboardServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DashboardServiceUtils.ts @@ -17,24 +17,94 @@ import { DashboardConnection, DashboardServiceType, } from '../generated/entity/services/dashboardService'; -import customDashboardConnection from '../jsons/connectionSchemas/connections/dashboard/customDashboardConnection.json'; -import domoDashboardConnection from '../jsons/connectionSchemas/connections/dashboard/domoDashboardConnection.json'; -import grafanaConnection from '../jsons/connectionSchemas/connections/dashboard/grafanaConnection.json'; -import hexConnection from '../jsons/connectionSchemas/connections/dashboard/hexConnection.json'; -import lightdashConnection from '../jsons/connectionSchemas/connections/dashboard/lightdashConnection.json'; -import lookerConnection from '../jsons/connectionSchemas/connections/dashboard/lookerConnection.json'; -import metabaseConnection from '../jsons/connectionSchemas/connections/dashboard/metabaseConnection.json'; -import microStrategyConnection from '../jsons/connectionSchemas/connections/dashboard/microStrategyConnection.json'; -import modeConnection from '../jsons/connectionSchemas/connections/dashboard/modeConnection.json'; -import powerBIConnection from '../jsons/connectionSchemas/connections/dashboard/powerBIConnection.json'; -import qlikcloudConnection from '../jsons/connectionSchemas/connections/dashboard/qlikCloudConnection.json'; -import qliksenseConnection from '../jsons/connectionSchemas/connections/dashboard/qlikSenseConnection.json'; -import quicksightConnection from '../jsons/connectionSchemas/connections/dashboard/quickSightConnection.json'; -import redashConnection from '../jsons/connectionSchemas/connections/dashboard/redashConnection.json'; -import sigmaConnection from '../jsons/connectionSchemas/connections/dashboard/sigmaConnection.json'; -import ssrsConnection from '../jsons/connectionSchemas/connections/dashboard/ssrsConnection.json'; -import supersetConnection from '../jsons/connectionSchemas/connections/dashboard/supersetConnection.json'; -import tableauConnection from '../jsons/connectionSchemas/connections/dashboard/tableauConnection.json'; + +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const dashboardSchemaLoaders: Partial< + Record +> = { + [DashboardServiceType.Looker]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/lookerConnection.json' + ), + [DashboardServiceType.Metabase]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/metabaseConnection.json' + ), + [DashboardServiceType.Mode]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/modeConnection.json' + ), + [DashboardServiceType.PowerBI]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/powerBIConnection.json' + ), + [DashboardServiceType.Redash]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/redashConnection.json' + ), + [DashboardServiceType.Superset]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/supersetConnection.json' + ), + [DashboardServiceType.Sigma]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/sigmaConnection.json' + ), + [DashboardServiceType.Tableau]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/tableauConnection.json' + ), + [DashboardServiceType.DomoDashboard]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/domoDashboardConnection.json' + ), + [DashboardServiceType.CustomDashboard]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/customDashboardConnection.json' + ), + [DashboardServiceType.QuickSight]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/quickSightConnection.json' + ), + [DashboardServiceType.QlikSense]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/qlikSenseConnection.json' + ), + [DashboardServiceType.QlikCloud]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/qlikCloudConnection.json' + ), + [DashboardServiceType.Lightdash]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/lightdashConnection.json' + ), + [DashboardServiceType.MicroStrategy]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/microStrategyConnection.json' + ), + [DashboardServiceType.Grafana]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/grafanaConnection.json' + ), + [DashboardServiceType.Hex]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/hexConnection.json' + ), + [DashboardServiceType.Ssrs]: () => + import( + '../jsons/connectionSchemas/connections/dashboard/ssrsConnection.json' + ), +}; + +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; + + return maybeDefault ?? (mod as Record); +}; export const getDashboardURL = (config: DashboardConnection['config']) => { return !isUndefined(config) && !isEmpty(config.hostPort) @@ -42,108 +112,14 @@ export const getDashboardURL = (config: DashboardConnection['config']) => { : '--'; }; -export const getDashboardConfig = (type: DashboardServiceType) => { - let schema = {}; +export const getDashboardConfig = async (type: DashboardServiceType) => { + const loader = dashboardSchemaLoaders[type]; + let schema: Record = {}; const uiSchema = { ...COMMON_UI_SCHEMA }; - switch (type) { - case DashboardServiceType.Looker: { - schema = lookerConnection; - - break; - } - case DashboardServiceType.Metabase: { - schema = metabaseConnection; - - break; - } - case DashboardServiceType.Mode: { - schema = modeConnection; - - break; - } - case DashboardServiceType.PowerBI: { - schema = powerBIConnection; - - break; - } - case DashboardServiceType.Redash: { - schema = redashConnection; - - break; - } - case DashboardServiceType.Superset: { - schema = supersetConnection; - - break; - } - case DashboardServiceType.Sigma: { - schema = sigmaConnection; - - break; - } - case DashboardServiceType.Tableau: { - schema = tableauConnection; - - break; - } - case DashboardServiceType.DomoDashboard: { - schema = domoDashboardConnection; - - break; - } - case DashboardServiceType.CustomDashboard: { - schema = customDashboardConnection; - - break; - } - - case DashboardServiceType.QuickSight: { - schema = quicksightConnection; - - break; - } - - case DashboardServiceType.QlikSense: { - schema = qliksenseConnection; - - break; - } - - case DashboardServiceType.QlikCloud: { - schema = qlikcloudConnection; - - break; - } - - case DashboardServiceType.Lightdash: { - schema = lightdashConnection; - - break; - } - - case DashboardServiceType.MicroStrategy: { - schema = microStrategyConnection; - - break; - } - - case DashboardServiceType.Grafana: { - schema = grafanaConnection; - - break; - } - - case DashboardServiceType.Hex: { - schema = hexConnection; - - break; - } - - case DashboardServiceType.Ssrs: { - schema = ssrsConnection; - break; - } + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightChartUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightChartUtils.tsx new file mode 100644 index 000000000000..5889884b02eb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightChartUtils.tsx @@ -0,0 +1,211 @@ +/* + * Copyright 2022 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Card, Typography } from 'antd'; +import { isEmpty, startCase, uniqBy } from 'lodash'; +import { + CartesianGrid, + LegendProps, + Line, + LineChart, + Surface, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { + DEFAULT_CHART_OPACITY, + GRAPH_BACKGROUND_COLOR, + GRAYED_OUT_COLOR, + HOVER_CHART_OPACITY, +} from '../constants/constants'; +import { BAR_CHART_MARGIN } from '../constants/DataInsight.constants'; +import { DataInsightChartTooltipProps } from '../interface/data-insight.interface'; +import { axisTickFormatter } from './ChartUtils'; +import { entityChartColor } from './CommonUtils'; +import { getEntryFormattedValue, getRandomHexColor } from './DataInsightUtils'; +import { customFormatDateTime, formatDate } from './date-time/DateTimeUtils'; + +export const renderLegend = ( + legendData: LegendProps, + activeKeys = [] as string[], + valueFormatter?: (value: string) => string +) => { + const { payload = [] } = legendData; + + return ( +

    + {payload.map((entry, index) => { + const isActive = + activeKeys.length === 0 || activeKeys.includes(entry.value); + + return ( +
  • + legendData.onClick && legendData.onClick(entry, index, e) + } + onMouseEnter={(e) => + legendData.onMouseEnter && + legendData.onMouseEnter(entry, index, e) + } + onMouseLeave={(e) => + legendData.onMouseLeave && + legendData.onMouseLeave(entry, index, e) + }> + + + + + {valueFormatter ? valueFormatter(entry.value) : entry.value} + +
  • + ); + })} +
+ ); +}; + +export const CustomTooltip = (props: DataInsightChartTooltipProps) => { + const { + active, + cardStyles, + customValueKey, + dateTimeFormatter = formatDate, + isPercentage, + labelStyles, + listContainerStyles, + payload = [], + timeStampKey = 'timestampValue', + titleStyles, + transformLabel = true, + valueFormatter, + valueStyles, + } = props; + + if (active && payload && payload.length) { + const timestamp = + timeStampKey === 'term' + ? payload[0].payload[timeStampKey] + : dateTimeFormatter(payload[0].payload[timeStampKey] || 0); + const payloadValue = uniqBy(payload, 'dataKey'); + + return ( + + {timestamp} + + }> +
    + {payloadValue.map((entry, index) => { + const value = customValueKey + ? entry.payload[customValueKey] + : entry.value; + + return ( +
  • + + + + + + {transformLabel + ? startCase(entry.name ?? (entry.dataKey as string)) + : entry.name ?? (entry.dataKey as string)} + + + + {valueFormatter + ? valueFormatter(value, entry.name ?? entry.dataKey) + : getEntryFormattedValue(value, isPercentage)} + +
  • + ); + })} +
+
+ ); + } + + return null; +}; + +export const renderDataInsightLineChart = ( + graphData: Array>, + labels: string[], + activeKeys: string[], + activeMouseHoverKey: string, + isPercentage: boolean +) => { + return ( + + + + } + wrapperStyle={{ pointerEvents: 'auto' }} + /> + customFormatDateTime(value, 'MMM dd')} + type="category" + /> + axisTickFormatter(value, '%') + : undefined + } + /> + + {labels.map((s, i) => ( + + ))} + + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.test.tsx index ee6728317e6c..334df1d3d889 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.test.tsx @@ -12,7 +12,7 @@ */ import { render } from '@testing-library/react'; import { DataInsightChartTooltipProps } from '../interface/data-insight.interface'; -import { CustomTooltip } from './DataInsightUtils'; +import { CustomTooltip } from './DataInsightChartUtils'; describe('CustomTooltip', () => { const defaultProps: DataInsightChartTooltipProps = { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.tsx index f573337ed0e6..4ac5ea671865 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataInsightUtils.tsx @@ -11,44 +11,22 @@ * limitations under the License. */ -import { Card, Typography } from 'antd'; import { first, get, - isEmpty, isInteger, isString, isUndefined, last, meanBy, round, - startCase, sumBy, toNumber, - uniqBy, } from 'lodash'; import { DateTime } from 'luxon'; -import { - CartesianGrid, - LegendProps, - Line, - LineChart, - Surface, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; import { RangePickerProps } from '../components/common/DatePicker/DatePicker'; +import { PLACEHOLDER_ROUTE_TAB, ROUTES } from '../constants/constants'; import { - DEFAULT_CHART_OPACITY, - GRAPH_BACKGROUND_COLOR, - GRAYED_OUT_COLOR, - HOVER_CHART_OPACITY, - PLACEHOLDER_ROUTE_TAB, - ROUTES, -} from '../constants/constants'; -import { - BAR_CHART_MARGIN, ENTITIES_SUMMARY_LIST, WEB_SUMMARY_LIST, } from '../constants/DataInsight.constants'; @@ -60,62 +38,13 @@ import { import { DailyActiveUsers } from '../generated/dataInsight/type/dailyActiveUsers'; import { ChartValue, - DataInsightChartTooltipProps, DataInsightTabs, } from '../interface/data-insight.interface'; import { DataInsightCustomChartResult } from '../rest/DataInsightAPI'; -import { entityChartColor } from '../utils/CommonUtils'; -import { axisTickFormatter } from './ChartUtils'; import { pluralize } from './CommonUtils'; -import { customFormatDateTime, formatDate } from './date-time/DateTimeUtils'; +import { customFormatDateTime } from './date-time/DateTimeUtils'; import { t, translateWithNestedKeys } from './i18next/LocalUtil'; -export const renderLegend = ( - legendData: LegendProps, - activeKeys = [] as string[], - valueFormatter?: (value: string) => string -) => { - const { payload = [] } = legendData; - - return ( -
    - {payload.map((entry, index) => { - const isActive = - activeKeys.length === 0 || activeKeys.includes(entry.value); - - return ( -
  • - legendData.onClick && legendData.onClick(entry, index, e) - } - onMouseEnter={(e) => - legendData.onMouseEnter && - legendData.onMouseEnter(entry, index, e) - } - onMouseLeave={(e) => - legendData.onMouseLeave && - legendData.onMouseLeave(entry, index, e) - }> - - - - - {valueFormatter ? valueFormatter(entry.value) : entry.value} - -
  • - ); - })} -
- ); -}; - export const getEntryFormattedValue = ( value: number | string | undefined, isPercentage?: boolean @@ -138,82 +67,6 @@ export const getEntryFormattedValue = ( } }; -export const CustomTooltip = (props: DataInsightChartTooltipProps) => { - const { - active, - cardStyles, - customValueKey, - dateTimeFormatter = formatDate, - isPercentage, - labelStyles, - listContainerStyles, - payload = [], - timeStampKey = 'timestampValue', - titleStyles, - transformLabel = true, - valueFormatter, - valueStyles, - } = props; - - if (active && payload && payload.length) { - // we need to check if the xAxis is a date or not. - const timestamp = - timeStampKey === 'term' - ? payload[0].payload[timeStampKey] - : dateTimeFormatter(payload[0].payload[timeStampKey] || 0); - const payloadValue = uniqBy(payload, 'dataKey'); - - return ( - - {timestamp} - - }> -
    - {payloadValue.map((entry, index) => { - const value = customValueKey - ? entry.payload[customValueKey] - : entry.value; - - return ( -
  • - - - - - - {transformLabel - ? startCase(entry.name ?? (entry.dataKey as string)) - : entry.name ?? (entry.dataKey as string)} - - - - {valueFormatter - ? valueFormatter(value, entry.name ?? entry.dataKey) - : getEntryFormattedValue(value, isPercentage)} - -
  • - ); - })} -
-
- ); - } - - return null; -}; - /** * takes timestamps and raw data as inputs and return the graph data by mapping timestamp * @param timestamps timestamps array @@ -495,59 +348,6 @@ export const isPercentageSystemGraph = (graph: SystemChartType) => { ].includes(graph); }; -export const renderDataInsightLineChart = ( - graphData: Array>, - labels: string[], - activeKeys: string[], - activeMouseHoverKey: string, - isPercentage: boolean -) => { - return ( - - - - } - wrapperStyle={{ pointerEvents: 'auto' }} - /> - customFormatDateTime(value, 'MMM dd')} - type="category" - /> - axisTickFormatter(value, '%') - : undefined - } - /> - - {labels.map((s, i) => ( - - ))} - - ); -}; - export const getQueryFilterForDataInsightChart = ( teamFilter?: string[], tierFilter?: string[] diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/CustomDQTooltip.component.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/CustomDQTooltip.component.tsx new file mode 100644 index 000000000000..7ffa170cb0f1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/CustomDQTooltip.component.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { startCase, uniqBy } from 'lodash'; +import { Surface } from 'recharts'; +import { DataInsightChartTooltipProps } from '../../interface/data-insight.interface'; +import { getEntryFormattedValue } from '../DataInsightUtils'; +import { formatDate } from '../date-time/DateTimeUtils'; + +export const CustomDQTooltip = (props: DataInsightChartTooltipProps) => { + const { + active, + dateTimeFormatter = formatDate, + isPercentage, + payload = [], + timeStampKey = 'timestampValue', + transformLabel = true, + valueFormatter, + displayDateInHeader = true, + } = props; + + if (active && payload?.length) { + const timestamp = displayDateInHeader + ? dateTimeFormatter(payload[0].payload[timeStampKey] || 0) + : payload[0].payload[timeStampKey]; + + const payloadValue = uniqBy(payload, 'dataKey'); + + return ( +
+

+ {timestamp} +

+
+
+ {payloadValue.map((entry, index) => { + const value = entry.value; + + return ( +
+ + + + + + {transformLabel + ? startCase(entry.name ?? (entry.dataKey as string)) + : entry.name ?? (entry.dataKey as string)} + + + + {valueFormatter + ? valueFormatter(value, entry.name ?? entry.dataKey) + : getEntryFormattedValue(value, isPercentage)} + +
+ ); + })} +
+
+ ); + } + + return null; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.tsx index fa0d3d64f652..ba58b6e0ef40 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DataQuality/DataQualityUtils.tsx @@ -20,11 +20,8 @@ import { omit, omitBy, parseInt, - startCase, - uniqBy, } from 'lodash'; import QueryString from 'qs'; -import { Surface } from 'recharts'; import { ReactComponent as AccuracyIcon } from '../../assets/svg/ic-accuracy.svg'; import { ReactComponent as ColumnIcon } from '../../assets/svg/ic-column.svg'; import { ReactComponent as CompletenessIcon } from '../../assets/svg/ic-completeness.svg'; @@ -55,15 +52,12 @@ import { TestDataType, TestDefinition, } from '../../generated/tests/testDefinition'; -import { DataInsightChartTooltipProps } from '../../interface/data-insight.interface'; import { TableSearchSource } from '../../interface/search.interface'; import { DataQualityDashboardChartFilters, DataQualityPageTabs, } from '../../pages/DataQuality/DataQualityPage.interface'; import { ListTestCaseParamsBySearch } from '../../rest/testAPI'; -import { getEntryFormattedValue } from '../DataInsightUtils'; -import { formatDate } from '../date-time/DateTimeUtils'; import EntityLink from '../EntityLink'; import { getColumnNameFromEntityLink } from '../EntityUtils'; import { getEntityFQN } from '../FeedUtils'; @@ -442,70 +436,6 @@ export const TEST_LEVEL_OPTIONS: SelectionOption[] = [ }, ]; -export const CustomDQTooltip = (props: DataInsightChartTooltipProps) => { - const { - active, - dateTimeFormatter = formatDate, - isPercentage, - payload = [], - timeStampKey = 'timestampValue', - transformLabel = true, - valueFormatter, - displayDateInHeader = true, - } = props; - - if (active && payload?.length) { - // we need to check if the xAxis is a date or not. - const timestamp = displayDateInHeader - ? dateTimeFormatter(payload[0].payload[timeStampKey] || 0) - : payload[0].payload[timeStampKey]; - - const payloadValue = uniqBy(payload, 'dataKey'); - - return ( -
-

- {timestamp} -

-
-
- {payloadValue.map((entry, index) => { - const value = entry.value; - - return ( -
- - - - - - {transformLabel - ? startCase(entry.name ?? (entry.dataKey as string)) - : entry.name ?? (entry.dataKey as string)} - - - - {valueFormatter - ? valueFormatter(value, entry.name ?? entry.dataKey) - : getEntryFormattedValue(value, isPercentage)} - -
- ); - })} -
-
- ); - } - - return null; -}; - export type TestCaseCountByStatus = { success: number; failed: number; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseServiceUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseServiceUtils.test.tsx index 8933ed44f4d4..9cc5d9b4b946 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseServiceUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseServiceUtils.test.tsx @@ -139,8 +139,8 @@ describe('ExtraDatabaseServiceDropdownOptions', () => { }); describe('getDatabaseConfig', () => { - it('should return correct schema and UI schema for MySQL', () => { - const result = getDatabaseConfig(DatabaseServiceType.Mysql); + it('should return correct schema and UI schema for MySQL', async () => { + const result = await getDatabaseConfig(DatabaseServiceType.Mysql); expect(result).toHaveProperty('schema'); expect(result).toHaveProperty('uiSchema'); @@ -148,8 +148,8 @@ describe('getDatabaseConfig', () => { expect(result.uiSchema).toEqual(COMMON_UI_SCHEMA); }); - it('should return correct schema and UI schema for Postgres', () => { - const result = getDatabaseConfig(DatabaseServiceType.Postgres); + it('should return correct schema and UI schema for Postgres', async () => { + const result = await getDatabaseConfig(DatabaseServiceType.Postgres); expect(result).toHaveProperty('schema'); expect(result).toHaveProperty('uiSchema'); @@ -157,8 +157,8 @@ describe('getDatabaseConfig', () => { expect(result.uiSchema).toEqual(COMMON_UI_SCHEMA); }); - it('should return correct schema and UI schema for Snowflake', () => { - const result = getDatabaseConfig(DatabaseServiceType.Snowflake); + it('should return correct schema and UI schema for Snowflake', async () => { + const result = await getDatabaseConfig(DatabaseServiceType.Snowflake); expect(result).toHaveProperty('schema'); expect(result).toHaveProperty('uiSchema'); @@ -166,8 +166,8 @@ describe('getDatabaseConfig', () => { expect(result.uiSchema).toEqual(COMMON_UI_SCHEMA); }); - it('should return correct schema and UI schema for BigQuery', () => { - const result = getDatabaseConfig(DatabaseServiceType.BigQuery); + it('should return correct schema and UI schema for BigQuery', async () => { + const result = await getDatabaseConfig(DatabaseServiceType.BigQuery); expect(result).toHaveProperty('schema'); expect(result).toHaveProperty('uiSchema'); @@ -175,8 +175,8 @@ describe('getDatabaseConfig', () => { expect(result.uiSchema).toEqual(COMMON_UI_SCHEMA); }); - it('should return correct schema and UI schema for CustomDatabase', () => { - const result = getDatabaseConfig(DatabaseServiceType.CustomDatabase); + it('should return correct schema and UI schema for CustomDatabase', async () => { + const result = await getDatabaseConfig(DatabaseServiceType.CustomDatabase); expect(result).toHaveProperty('schema'); expect(result).toHaveProperty('uiSchema'); @@ -184,8 +184,10 @@ describe('getDatabaseConfig', () => { expect(result.uiSchema).toEqual(COMMON_UI_SCHEMA); }); - it('should return empty schema and default UI schema for unknown database type', () => { - const result = getDatabaseConfig('UnknownType' as DatabaseServiceType); + it('should return empty schema and default UI schema for unknown database type', async () => { + const result = await getDatabaseConfig( + 'UnknownType' as DatabaseServiceType + ); expect(result).toHaveProperty('schema'); expect(result).toHaveProperty('uiSchema'); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseServiceUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseServiceUtils.tsx index 60ea17ed2e35..081f3098f4c7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseServiceUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DatabaseServiceUtils.tsx @@ -22,317 +22,230 @@ import { COMMON_UI_SCHEMA } from '../constants/ServiceUISchema.constant'; import { OperationPermission } from '../context/PermissionProvider/PermissionProvider.interface'; import { EntityType } from '../enums/entity.enum'; import { DatabaseServiceType } from '../generated/entity/services/databaseService'; -import athenaConnection from '../jsons/connectionSchemas/connections/database/athenaConnection.json'; -import azureSQLConnection from '../jsons/connectionSchemas/connections/database/azureSQLConnection.json'; -import bigQueryConnection from '../jsons/connectionSchemas/connections/database/bigQueryConnection.json'; -import bigTableConnection from '../jsons/connectionSchemas/connections/database/bigTableConnection.json'; -import burstiqConnection from '../jsons/connectionSchemas/connections/database/burstIQConnection.json'; -import cassandraConnection from '../jsons/connectionSchemas/connections/database/cassandraConnection.json'; -import clickhouseConnection from '../jsons/connectionSchemas/connections/database/clickhouseConnection.json'; -import cockroachConnection from '../jsons/connectionSchemas/connections/database/cockroachConnection.json'; -import couchbaseConnection from '../jsons/connectionSchemas/connections/database/couchbaseConnection.json'; -import customDatabaseConnection from '../jsons/connectionSchemas/connections/database/customDatabaseConnection.json'; -import databricksConnection from '../jsons/connectionSchemas/connections/database/databricksConnection.json'; -import DatalakeConnection from '../jsons/connectionSchemas/connections/database/datalakeConnection.json'; -import db2Connection from '../jsons/connectionSchemas/connections/database/db2Connection.json'; -import deltaLakeConnection from '../jsons/connectionSchemas/connections/database/deltaLakeConnection.json'; -import domoDatabaseConnection from '../jsons/connectionSchemas/connections/database/domoDatabaseConnection.json'; -import dorisConnection from '../jsons/connectionSchemas/connections/database/dorisConnection.json'; -import druidConnection from '../jsons/connectionSchemas/connections/database/druidConnection.json'; -import dynamoDBConnection from '../jsons/connectionSchemas/connections/database/dynamoDBConnection.json'; -import exasolConnection from '../jsons/connectionSchemas/connections/database/exasolConnection.json'; -import glueConnection from '../jsons/connectionSchemas/connections/database/glueConnection.json'; -import greenplumConnection from '../jsons/connectionSchemas/connections/database/greenplumConnection.json'; -import hiveConnection from '../jsons/connectionSchemas/connections/database/hiveConnection.json'; -import impalaConnection from '../jsons/connectionSchemas/connections/database/impalaConnection.json'; -import iometeConnection from '../jsons/connectionSchemas/connections/database/iometeConnection.json'; -import mariaDBConnection from '../jsons/connectionSchemas/connections/database/mariaDBConnection.json'; -import microsoftFabricConnection from '../jsons/connectionSchemas/connections/database/microsoftFabricConnection.json'; -import mongoDBConnection from '../jsons/connectionSchemas/connections/database/mongoDBConnection.json'; -import mssqlConnection from '../jsons/connectionSchemas/connections/database/mssqlConnection.json'; -import mysqlConnection from '../jsons/connectionSchemas/connections/database/mysqlConnection.json'; -import oracleConnection from '../jsons/connectionSchemas/connections/database/oracleConnection.json'; -import pinotConnection from '../jsons/connectionSchemas/connections/database/pinotDBConnection.json'; -import postgresConnection from '../jsons/connectionSchemas/connections/database/postgresConnection.json'; -import prestoConnection from '../jsons/connectionSchemas/connections/database/prestoConnection.json'; -import questdbConnection from '../jsons/connectionSchemas/connections/database/questdbConnection.json'; -import redshiftConnection from '../jsons/connectionSchemas/connections/database/redshiftConnection.json'; -import salesforceConnection from '../jsons/connectionSchemas/connections/database/salesforceConnection.json'; -import sapErpConnection from '../jsons/connectionSchemas/connections/database/sapErpConnection.json'; -import sapHanaConnection from '../jsons/connectionSchemas/connections/database/sapHanaConnection.json'; -import sasConnection from '../jsons/connectionSchemas/connections/database/sasConnection.json'; -import singleStoreConnection from '../jsons/connectionSchemas/connections/database/singleStoreConnection.json'; -import snowflakeConnection from '../jsons/connectionSchemas/connections/database/snowflakeConnection.json'; -import sqliteConnection from '../jsons/connectionSchemas/connections/database/sqliteConnection.json'; -import starrocksConnection from '../jsons/connectionSchemas/connections/database/starrocksConnection.json'; -import synapseConnection from '../jsons/connectionSchemas/connections/database/synapseConnection.json'; -import teradataConnection from '../jsons/connectionSchemas/connections/database/teradataConnection.json'; -import timescaleConnection from '../jsons/connectionSchemas/connections/database/timescaleConnection.json'; -import trinoConnection from '../jsons/connectionSchemas/connections/database/trinoConnection.json'; -import unityCatalogConnection from '../jsons/connectionSchemas/connections/database/unityCatalogConnection.json'; -import verticaConnection from '../jsons/connectionSchemas/connections/database/verticaConnection.json'; import { exportDatabaseServiceDetailsInCSV } from '../rest/serviceAPI'; import { getEntityImportPath } from './EntityUtils'; import { t } from './i18next/LocalUtil'; -export const getDatabaseConfig = (type: DatabaseServiceType) => { - let schema = {}; - const uiSchema = { ...COMMON_UI_SCHEMA }; - switch (type as unknown as DatabaseServiceType) { - case DatabaseServiceType.Athena: { - schema = athenaConnection; - - break; - } - case DatabaseServiceType.AzureSQL: { - schema = azureSQLConnection; - - break; - } - case DatabaseServiceType.BigQuery: { - schema = bigQueryConnection; - - break; - } - case DatabaseServiceType.BigTable: { - schema = bigTableConnection; - - break; - } - case DatabaseServiceType.Clickhouse: { - schema = clickhouseConnection; - - break; - } - case DatabaseServiceType.Cockroach: { - schema = cockroachConnection; - - break; - } - case DatabaseServiceType.Databricks: { - schema = databricksConnection; - - break; - } - case DatabaseServiceType.Datalake: { - schema = DatalakeConnection; - - break; - } - case DatabaseServiceType.Db2: { - schema = db2Connection; - - break; - } - case DatabaseServiceType.DeltaLake: { - schema = deltaLakeConnection; - - break; - } - case DatabaseServiceType.Doris: { - schema = dorisConnection; - - break; - } - case DatabaseServiceType.StarRocks: { - schema = starrocksConnection; - - break; - } - case DatabaseServiceType.Druid: { - schema = druidConnection; - - break; - } - - case DatabaseServiceType.DynamoDB: { - schema = dynamoDBConnection; - - break; - } - case DatabaseServiceType.Exasol: { - schema = exasolConnection; - - break; - } - case DatabaseServiceType.Glue: { - schema = glueConnection; - - break; - } - case DatabaseServiceType.Hive: { - schema = hiveConnection; - - break; - } - case DatabaseServiceType.Impala: { - schema = impalaConnection; - - break; - } - case DatabaseServiceType.MariaDB: { - schema = mariaDBConnection; - - break; - } - case DatabaseServiceType.Mssql: { - schema = mssqlConnection; - - break; - } - case DatabaseServiceType.Mysql: { - schema = mysqlConnection; - - break; - } - case DatabaseServiceType.Oracle: { - schema = oracleConnection; - - break; - } - case DatabaseServiceType.Postgres: { - schema = postgresConnection; - - break; - } - case DatabaseServiceType.Presto: { - schema = prestoConnection; - - break; - } - case DatabaseServiceType.QuestDB: { - schema = questdbConnection; - - break; - } - case DatabaseServiceType.Redshift: { - schema = redshiftConnection; - - break; - } - case DatabaseServiceType.Salesforce: { - schema = salesforceConnection; - - break; - } - case DatabaseServiceType.SingleStore: { - schema = singleStoreConnection; - - break; - } - case DatabaseServiceType.Snowflake: { - schema = snowflakeConnection; - - break; - } - case DatabaseServiceType.SQLite: { - schema = sqliteConnection; - - break; - } - case DatabaseServiceType.Synapse: { - schema = synapseConnection; - - break; - } - case DatabaseServiceType.Trino: { - schema = trinoConnection; - - break; - } - case DatabaseServiceType.Vertica: { - schema = verticaConnection; - - break; - } - case DatabaseServiceType.CustomDatabase: { - schema = customDatabaseConnection; - - break; - } - case DatabaseServiceType.DomoDatabase: { - schema = domoDatabaseConnection; - - break; - } - case DatabaseServiceType.SapHana: { - schema = sapHanaConnection; - - break; - } - case DatabaseServiceType.SapERP: { - schema = sapErpConnection; - - break; - } - case DatabaseServiceType.MongoDB: { - schema = mongoDBConnection; - - break; - } - case DatabaseServiceType.Cassandra: { - schema = cassandraConnection; - - break; - } - case DatabaseServiceType.Couchbase: { - schema = couchbaseConnection; - - break; - } - case DatabaseServiceType.PinotDB: { - schema = pinotConnection; - - break; - } - case DatabaseServiceType.Greenplum: { - schema = greenplumConnection; - - break; - } - case DatabaseServiceType.UnityCatalog: { - schema = unityCatalogConnection; - - break; - } - case DatabaseServiceType.SAS: { - schema = sasConnection; - - break; - } - case DatabaseServiceType.Teradata: { - schema = teradataConnection; - - break; - } - case DatabaseServiceType.Timescale: { - schema = timescaleConnection; - - break; - } - case DatabaseServiceType.BurstIQ: { - schema = burstiqConnection; - - break; - } - - case DatabaseServiceType.MicrosoftFabric: { - schema = microsoftFabricConnection; - - break; - } +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const databaseSchemaLoaders: Partial< + Record +> = { + [DatabaseServiceType.Athena]: () => + import( + '../jsons/connectionSchemas/connections/database/athenaConnection.json' + ), + [DatabaseServiceType.AzureSQL]: () => + import( + '../jsons/connectionSchemas/connections/database/azureSQLConnection.json' + ), + [DatabaseServiceType.BigQuery]: () => + import( + '../jsons/connectionSchemas/connections/database/bigQueryConnection.json' + ), + [DatabaseServiceType.BigTable]: () => + import( + '../jsons/connectionSchemas/connections/database/bigTableConnection.json' + ), + [DatabaseServiceType.Clickhouse]: () => + import( + '../jsons/connectionSchemas/connections/database/clickhouseConnection.json' + ), + [DatabaseServiceType.Cockroach]: () => + import( + '../jsons/connectionSchemas/connections/database/cockroachConnection.json' + ), + [DatabaseServiceType.Databricks]: () => + import( + '../jsons/connectionSchemas/connections/database/databricksConnection.json' + ), + [DatabaseServiceType.Datalake]: () => + import( + '../jsons/connectionSchemas/connections/database/datalakeConnection.json' + ), + [DatabaseServiceType.Db2]: () => + import( + '../jsons/connectionSchemas/connections/database/db2Connection.json' + ), + [DatabaseServiceType.DeltaLake]: () => + import( + '../jsons/connectionSchemas/connections/database/deltaLakeConnection.json' + ), + [DatabaseServiceType.Doris]: () => + import( + '../jsons/connectionSchemas/connections/database/dorisConnection.json' + ), + [DatabaseServiceType.StarRocks]: () => + import( + '../jsons/connectionSchemas/connections/database/starrocksConnection.json' + ), + [DatabaseServiceType.Druid]: () => + import( + '../jsons/connectionSchemas/connections/database/druidConnection.json' + ), + [DatabaseServiceType.DynamoDB]: () => + import( + '../jsons/connectionSchemas/connections/database/dynamoDBConnection.json' + ), + [DatabaseServiceType.Exasol]: () => + import( + '../jsons/connectionSchemas/connections/database/exasolConnection.json' + ), + [DatabaseServiceType.Glue]: () => + import( + '../jsons/connectionSchemas/connections/database/glueConnection.json' + ), + [DatabaseServiceType.Hive]: () => + import( + '../jsons/connectionSchemas/connections/database/hiveConnection.json' + ), + [DatabaseServiceType.Impala]: () => + import( + '../jsons/connectionSchemas/connections/database/impalaConnection.json' + ), + [DatabaseServiceType.MariaDB]: () => + import( + '../jsons/connectionSchemas/connections/database/mariaDBConnection.json' + ), + [DatabaseServiceType.Mssql]: () => + import( + '../jsons/connectionSchemas/connections/database/mssqlConnection.json' + ), + [DatabaseServiceType.Mysql]: () => + import( + '../jsons/connectionSchemas/connections/database/mysqlConnection.json' + ), + [DatabaseServiceType.Oracle]: () => + import( + '../jsons/connectionSchemas/connections/database/oracleConnection.json' + ), + [DatabaseServiceType.Postgres]: () => + import( + '../jsons/connectionSchemas/connections/database/postgresConnection.json' + ), + [DatabaseServiceType.Presto]: () => + import( + '../jsons/connectionSchemas/connections/database/prestoConnection.json' + ), + [DatabaseServiceType.QuestDB]: () => + import( + '../jsons/connectionSchemas/connections/database/questdbConnection.json' + ), + [DatabaseServiceType.Redshift]: () => + import( + '../jsons/connectionSchemas/connections/database/redshiftConnection.json' + ), + [DatabaseServiceType.Salesforce]: () => + import( + '../jsons/connectionSchemas/connections/database/salesforceConnection.json' + ), + [DatabaseServiceType.SingleStore]: () => + import( + '../jsons/connectionSchemas/connections/database/singleStoreConnection.json' + ), + [DatabaseServiceType.Snowflake]: () => + import( + '../jsons/connectionSchemas/connections/database/snowflakeConnection.json' + ), + [DatabaseServiceType.SQLite]: () => + import( + '../jsons/connectionSchemas/connections/database/sqliteConnection.json' + ), + [DatabaseServiceType.Synapse]: () => + import( + '../jsons/connectionSchemas/connections/database/synapseConnection.json' + ), + [DatabaseServiceType.Trino]: () => + import( + '../jsons/connectionSchemas/connections/database/trinoConnection.json' + ), + [DatabaseServiceType.Vertica]: () => + import( + '../jsons/connectionSchemas/connections/database/verticaConnection.json' + ), + [DatabaseServiceType.CustomDatabase]: () => + import( + '../jsons/connectionSchemas/connections/database/customDatabaseConnection.json' + ), + [DatabaseServiceType.DomoDatabase]: () => + import( + '../jsons/connectionSchemas/connections/database/domoDatabaseConnection.json' + ), + [DatabaseServiceType.SapHana]: () => + import( + '../jsons/connectionSchemas/connections/database/sapHanaConnection.json' + ), + [DatabaseServiceType.SapERP]: () => + import( + '../jsons/connectionSchemas/connections/database/sapErpConnection.json' + ), + [DatabaseServiceType.MongoDB]: () => + import( + '../jsons/connectionSchemas/connections/database/mongoDBConnection.json' + ), + [DatabaseServiceType.Cassandra]: () => + import( + '../jsons/connectionSchemas/connections/database/cassandraConnection.json' + ), + [DatabaseServiceType.Couchbase]: () => + import( + '../jsons/connectionSchemas/connections/database/couchbaseConnection.json' + ), + [DatabaseServiceType.PinotDB]: () => + import( + '../jsons/connectionSchemas/connections/database/pinotDBConnection.json' + ), + [DatabaseServiceType.Greenplum]: () => + import( + '../jsons/connectionSchemas/connections/database/greenplumConnection.json' + ), + [DatabaseServiceType.UnityCatalog]: () => + import( + '../jsons/connectionSchemas/connections/database/unityCatalogConnection.json' + ), + [DatabaseServiceType.SAS]: () => + import( + '../jsons/connectionSchemas/connections/database/sasConnection.json' + ), + [DatabaseServiceType.Teradata]: () => + import( + '../jsons/connectionSchemas/connections/database/teradataConnection.json' + ), + [DatabaseServiceType.Timescale]: () => + import( + '../jsons/connectionSchemas/connections/database/timescaleConnection.json' + ), + [DatabaseServiceType.BurstIQ]: () => + import( + '../jsons/connectionSchemas/connections/database/burstIQConnection.json' + ), + [DatabaseServiceType.MicrosoftFabric]: () => + import( + '../jsons/connectionSchemas/connections/database/microsoftFabricConnection.json' + ), + [DatabaseServiceType.Iomete]: () => + import( + '../jsons/connectionSchemas/connections/database/iometeConnection.json' + ), +}; - case DatabaseServiceType.Iomete: { - schema = iometeConnection; +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; - break; - } + return maybeDefault ?? (mod as Record); +}; - default: { - schema = {}; +export const getDatabaseConfig = async (type: DatabaseServiceType) => { + const loader = databaseSchemaLoaders[type]; + let schema: Record = {}; + const uiSchema = { ...COMMON_UI_SCHEMA }; - break; - } + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DriveServiceUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DriveServiceUtils.test.ts index c22ecf825695..609529d1366b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DriveServiceUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DriveServiceUtils.test.ts @@ -78,46 +78,45 @@ describe('DriveServiceUtils', () => { }); describe('getDriveConfig', () => { - it('should return custom drive configuration for CustomDrive type', () => { + it('should return custom drive configuration for CustomDrive type', async () => { const expectedResult = { schema: customDriveConnection, uiSchema: COMMON_UI_SCHEMA, }; - const result = getDriveConfig(DriveServiceType.CustomDrive); + const result = await getDriveConfig(DriveServiceType.CustomDrive); expect(mockedCloneDeep).toHaveBeenCalledWith(expectedResult); expect(result).toEqual(expectedResult); }); - it('should return google drive configuration for GoogleDrive type', () => { + it('should return google drive configuration for GoogleDrive type', async () => { const expectedResult = { schema: googleDriveConnection, uiSchema: COMMON_UI_SCHEMA, }; - const result = getDriveConfig(DriveServiceType.GoogleDrive); + const result = await getDriveConfig(DriveServiceType.GoogleDrive); expect(mockedCloneDeep).toHaveBeenCalledWith(expectedResult); expect(result).toEqual(expectedResult); }); - it('should return empty schema and common ui schema for unknown drive type', () => { + it('should return empty schema and common ui schema for unknown drive type', async () => { const unknownType = 'UnknownDrive' as DriveServiceType; const expectedResult = { schema: {}, uiSchema: COMMON_UI_SCHEMA, }; - const result = getDriveConfig(unknownType); + const result = await getDriveConfig(unknownType); expect(mockedCloneDeep).toHaveBeenCalledWith(expectedResult); expect(result).toEqual(expectedResult); }); - it('should return empty schema and common ui schema for default case', () => { - // Test the default case by passing undefined as type - getDriveConfig(undefined as unknown as DriveServiceType); + it('should return empty schema and common ui schema for default case', async () => { + await getDriveConfig(undefined as unknown as DriveServiceType); const expectedResult = { schema: {}, uiSchema: COMMON_UI_SCHEMA, @@ -126,8 +125,8 @@ describe('DriveServiceUtils', () => { expect(mockedCloneDeep).toHaveBeenCalledWith(expectedResult); }); - it('should create a deep clone of the configuration object', () => { - getDriveConfig(DriveServiceType.GoogleDrive); + it('should create a deep clone of the configuration object', async () => { + await getDriveConfig(DriveServiceType.GoogleDrive); expect(mockedCloneDeep).toHaveBeenCalledTimes(1); expect(mockedCloneDeep).toHaveBeenCalledWith({ @@ -136,24 +135,24 @@ describe('DriveServiceUtils', () => { }); }); - it('should not mutate the original COMMON_UI_SCHEMA object', () => { + it('should not mutate the original COMMON_UI_SCHEMA object', async () => { const originalUiSchema = { ...COMMON_UI_SCHEMA }; - getDriveConfig(DriveServiceType.CustomDrive); + await getDriveConfig(DriveServiceType.CustomDrive); expect(COMMON_UI_SCHEMA).toEqual(originalUiSchema); }); - it('should handle all valid DriveServiceType enum values', () => { + it('should handle all valid DriveServiceType enum values', async () => { const driveServiceTypes = [ DriveServiceType.CustomDrive, DriveServiceType.GoogleDrive, ]; - driveServiceTypes.forEach((type) => { - expect(() => getDriveConfig(type)).not.toThrow(); + for (const type of driveServiceTypes) { + await expect(getDriveConfig(type)).resolves.toBeDefined(); expect(mockedCloneDeep).toHaveBeenCalled(); - }); + } }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DriveServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DriveServiceUtils.ts index d66f9e87c768..af99c20a6bba 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DriveServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DriveServiceUtils.ts @@ -14,32 +14,39 @@ import { cloneDeep } from 'lodash'; import { COMMON_UI_SCHEMA } from '../constants/ServiceUISchema.constant'; import { DriveServiceType } from '../generated/entity/services/driveService'; -import customDriveConnection from '../jsons/connectionSchemas/connections/drive/customDriveConnection.json'; -import googleDriveConnection from '../jsons/connectionSchemas/connections/drive/googleDriveConnection.json'; -import sftpConnection from '../jsons/connectionSchemas/connections/drive/sftpConnection.json'; -export const getDriveConfig = (type: DriveServiceType) => { - let schema = {}; - const uiSchema = { ...COMMON_UI_SCHEMA }; +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; - switch (type) { - case DriveServiceType.CustomDrive: { - schema = customDriveConnection; +const driveSchemaLoaders: Partial> = { + [DriveServiceType.CustomDrive]: () => + import( + '../jsons/connectionSchemas/connections/drive/customDriveConnection.json' + ), + [DriveServiceType.GoogleDrive]: () => + import( + '../jsons/connectionSchemas/connections/drive/googleDriveConnection.json' + ), + [DriveServiceType.SFTP]: () => + import('../jsons/connectionSchemas/connections/drive/sftpConnection.json'), +}; - break; - } - case DriveServiceType.GoogleDrive: { - schema = googleDriveConnection; +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; - break; - } - case DriveServiceType.SFTP: { - schema = sftpConnection; + return maybeDefault ?? (mod as Record); +}; + +export const getDriveConfig = async (type: DriveServiceType) => { + const loader = driveSchemaLoaders[type]; + let schema: Record = {}; + const uiSchema = { ...COMMON_UI_SCHEMA }; - break; - } - default: - break; + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EdgeMidpointUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/EdgeMidpointUtils.ts index f255e416a573..8381bb389af6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EdgeMidpointUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EdgeMidpointUtils.ts @@ -10,7 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Edge, Node, Position } from 'reactflow'; +import type { Edge, Node } from 'reactflow'; +import { Position } from 'reactflow'; import { getEdgeCoordinates } from './CanvasUtils'; import { getEdgePathData } from './EntityLineageUtils'; import { getEntityName } from './EntityUtils'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EdgeStyleUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/EdgeStyleUtils.ts index 5f3442d8d69b..7faff9666c72 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EdgeStyleUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EdgeStyleUtils.ts @@ -11,7 +11,7 @@ * limitations under the License. */ import { Theme } from '@mui/material'; -import { Edge } from 'reactflow'; +import type { Edge } from 'reactflow'; export interface EdgeStyle { stroke: string; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityLineageUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityLineageUtils.tsx index bca9adb9cd99..96528a9b55da 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityLineageUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityLineageUtils.tsx @@ -32,18 +32,15 @@ import { import { EntityTags, LoadingState } from 'Models'; import { MouseEvent as ReactMouseEvent } from 'react'; import { Link } from 'react-router-dom'; +import type { Connection, Edge, Node, ReactFlowInstance } from 'reactflow'; import { - Connection, - Edge, getBezierPath, getConnectedEdges, getIncomers, getOutgoers, isNode, MarkerType, - Node, Position, - ReactFlowInstance, } from 'reactflow'; import { ReactComponent as DashboardIcon } from '../assets/svg/dashboard-grey.svg'; import { ReactComponent as MetricIcon } from '../assets/svg/metric.svg'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/IngestionListTableUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/IngestionListTableUtils.test.tsx index 183a0366934a..6656f1b8801d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/IngestionListTableUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/IngestionListTableUtils.test.tsx @@ -12,7 +12,7 @@ */ import { render, screen } from '@testing-library/react'; import { PipelineType } from '../generated/entity/services/ingestionPipelines/ingestionPipeline'; -import { getScheduleDescriptionTexts } from './date-time/DateTimeUtils'; +import { useScheduleDescriptionTexts } from '../hooks/useScheduleDescriptionTexts'; import { renderNameField, renderScheduleField, @@ -38,8 +38,8 @@ jest.mock('./StringsUtils', () => ({ stringToHTML: jest.fn((text) => text), })); -jest.mock('./date-time/DateTimeUtils', () => ({ - getScheduleDescriptionTexts: jest.fn().mockReturnValue({ +jest.mock('../hooks/useScheduleDescriptionTexts', () => ({ + useScheduleDescriptionTexts: jest.fn().mockReturnValue({ descriptionFirstPart: 'Every day', descriptionSecondPart: 'at 12:00 AM', }), @@ -104,10 +104,10 @@ describe('renderScheduleField', () => { expect(screen.getByText('at 12:00 AM')).toBeInTheDocument(); }); - it('should call getScheduleDescriptionTexts with correct schedule interval', () => { + it('should call useScheduleDescriptionTexts with correct schedule interval', () => { render(renderScheduleField('', mockRecord)); - expect(getScheduleDescriptionTexts).toHaveBeenCalledWith('0 0 * * *'); + expect(useScheduleDescriptionTexts).toHaveBeenCalledWith('0 0 * * *'); }); it('should render no data placeholder when schedule interval is not available', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/IngestionListTableUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/IngestionListTableUtils.tsx index 91335d34a1aa..1276886d101f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/IngestionListTableUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/IngestionListTableUtils.tsx @@ -23,7 +23,7 @@ import { IngestionPipeline, PipelineType, } from '../generated/entity/services/ingestionPipelines/ingestionPipeline'; -import { getScheduleDescriptionTexts } from './date-time/DateTimeUtils'; +import { useScheduleDescriptionTexts } from '../hooks/useScheduleDescriptionTexts'; import { getEntityName, highlightSearchText } from './EntityUtils'; import { t } from './i18next/LocalUtil'; import { stringToHTML } from './StringsUtils'; @@ -70,16 +70,13 @@ export const renderStatusField = (_: string, record: IngestionPipeline) => { ); }; -export const renderScheduleField = (_: string, record: IngestionPipeline) => { - if (isUndefined(record.airflowConfig?.scheduleInterval)) { - return ( - - {NO_DATA_PLACEHOLDER} - - ); - } +const ScheduleFieldCell = ({ + scheduleInterval, +}: { + scheduleInterval: string; +}) => { const { descriptionFirstPart, descriptionSecondPart } = - getScheduleDescriptionTexts(record.airflowConfig.scheduleInterval); + useScheduleDescriptionTexts(scheduleInterval); return ( @@ -107,3 +104,19 @@ export const renderScheduleField = (_: string, record: IngestionPipeline) => { ); }; + +export const renderScheduleField = (_: string, record: IngestionPipeline) => { + if (isUndefined(record.airflowConfig?.scheduleInterval)) { + return ( + + {NO_DATA_PLACEHOLDER} + + ); + } + + return ( + + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/MessagingServiceUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/MessagingServiceUtils.test.ts index 9cf1fa9b5ac3..b919bbf5e7e0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/MessagingServiceUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/MessagingServiceUtils.test.ts @@ -42,8 +42,8 @@ import { MessagingServiceType } from '../generated/entity/services/messagingServ import { getMessagingConfig } from './MessagingServiceUtils'; describe('MessagingServiceUtils', () => { - it('Kafka uiSchema should include ui:emptyValue for schemaRegistryTopicSuffixName', () => { - const config = getMessagingConfig(MessagingServiceType.Kafka); + it('Kafka uiSchema should include ui:emptyValue for schemaRegistryTopicSuffixName', async () => { + const config = await getMessagingConfig(MessagingServiceType.Kafka); expect(config.uiSchema).toMatchObject({ ...COMMON_UI_SCHEMA, @@ -53,8 +53,8 @@ describe('MessagingServiceUtils', () => { }); }); - it('Redpanda uiSchema should include ui:emptyValue for schemaRegistryTopicSuffixName', () => { - const config = getMessagingConfig(MessagingServiceType.Redpanda); + it('Redpanda uiSchema should include ui:emptyValue for schemaRegistryTopicSuffixName', async () => { + const config = await getMessagingConfig(MessagingServiceType.Redpanda); expect(config.uiSchema).toMatchObject({ ...COMMON_UI_SCHEMA, @@ -64,14 +64,14 @@ describe('MessagingServiceUtils', () => { }); }); - it('non-broker services should not include schemaRegistryTopicSuffixName uiSchema', () => { - const config = getMessagingConfig(MessagingServiceType.Kinesis); + it('non-broker services should not include schemaRegistryTopicSuffixName uiSchema', async () => { + const config = await getMessagingConfig(MessagingServiceType.Kinesis); expect(config.uiSchema).not.toHaveProperty('schemaRegistryTopicSuffixName'); }); - it('getMessagingConfig should return only common UI schema for invalid types', () => { - const config = getMessagingConfig('' as MessagingServiceType); + it('getMessagingConfig should return only common UI schema for invalid types', async () => { + const config = await getMessagingConfig('' as MessagingServiceType); expect(config.uiSchema).toEqual({ ...COMMON_UI_SCHEMA }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/MessagingServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/MessagingServiceUtils.ts index 0977fff44f77..2fed6f6d6037 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/MessagingServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/MessagingServiceUtils.ts @@ -17,16 +17,46 @@ import { MessagingConnection, MessagingServiceType, } from '../generated/entity/services/messagingService'; -import customMessagingConnection from '../jsons/connectionSchemas/connections/messaging/customMessagingConnection.json'; -import kafkaConnection from '../jsons/connectionSchemas/connections/messaging/kafkaConnection.json'; -import kinesisConnection from '../jsons/connectionSchemas/connections/messaging/kinesisConnection.json'; -import pubSubConnection from '../jsons/connectionSchemas/connections/messaging/pubSubConnection.json'; -import redpandaConnection from '../jsons/connectionSchemas/connections/messaging/redpandaConnection.json'; + +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const messagingSchemaLoaders: Partial< + Record +> = { + [MessagingServiceType.Kafka]: () => + import( + '../jsons/connectionSchemas/connections/messaging/kafkaConnection.json' + ), + [MessagingServiceType.Redpanda]: () => + import( + '../jsons/connectionSchemas/connections/messaging/redpandaConnection.json' + ), + [MessagingServiceType.CustomMessaging]: () => + import( + '../jsons/connectionSchemas/connections/messaging/customMessagingConnection.json' + ), + [MessagingServiceType.Kinesis]: () => + import( + '../jsons/connectionSchemas/connections/messaging/kinesisConnection.json' + ), + [MessagingServiceType.PubSub]: () => + import( + '../jsons/connectionSchemas/connections/messaging/pubSubConnection.json' + ), +}; + +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; + + return maybeDefault ?? (mod as Record); +}; export const getBrokers = (config: MessagingConnection['config']) => { let retVal: string | undefined; - // Change it to switch case if more than 1 conditions arise if (config?.type === MessagingServiceType.Kafka) { retVal = config.bootstrapServers; } @@ -40,40 +70,21 @@ const SCHEMA_REGISTRY_SUFFIX_UI_SCHEMA = { }, }; -export const getMessagingConfig = (type: MessagingServiceType) => { - let schema = {}; - const uiSchema = { ...COMMON_UI_SCHEMA }; - - switch (type) { - case MessagingServiceType.Kafka: - schema = kafkaConnection; - Object.assign(uiSchema, SCHEMA_REGISTRY_SUFFIX_UI_SCHEMA); +export const getMessagingConfig = async (type: MessagingServiceType) => { + const loader = messagingSchemaLoaders[type]; + let schema: Record = {}; + const uiSchema: Record = { ...COMMON_UI_SCHEMA }; - break; + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); - case MessagingServiceType.Redpanda: - schema = redpandaConnection; + if ( + type === MessagingServiceType.Kafka || + type === MessagingServiceType.Redpanda + ) { Object.assign(uiSchema, SCHEMA_REGISTRY_SUFFIX_UI_SCHEMA); - - break; - - case MessagingServiceType.CustomMessaging: - schema = customMessagingConnection; - - break; - - case MessagingServiceType.Kinesis: - schema = kinesisConnection; - - break; - - case MessagingServiceType.PubSub: - schema = pubSubConnection; - - break; - - default: - break; + } } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/MetadataServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/MetadataServiceUtils.ts index d1aa2a63a098..ac9d5d9cd148 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/MetadataServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/MetadataServiceUtils.ts @@ -14,35 +14,47 @@ import { cloneDeep } from 'lodash'; import { COMMON_UI_SCHEMA } from '../constants/ServiceUISchema.constant'; import { MetadataServiceType } from '../generated/entity/services/metadataService'; -import alationSinkConnection from '../jsons/connectionSchemas/connections/metadata/alationSinkConnection.json'; -import amundsenConnection from '../jsons/connectionSchemas/connections/metadata/amundsenConnection.json'; -import atlasConnection from '../jsons/connectionSchemas/connections/metadata/atlasConnection.json'; -import openMetadataConnection from '../jsons/connectionSchemas/connections/metadata/openMetadataConnection.json'; -export const getMetadataConfig = (type: MetadataServiceType) => { - let schema = {}; +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const metadataSchemaLoaders: Partial< + Record +> = { + [MetadataServiceType.Atlas]: () => + import( + '../jsons/connectionSchemas/connections/metadata/atlasConnection.json' + ), + [MetadataServiceType.Amundsen]: () => + import( + '../jsons/connectionSchemas/connections/metadata/amundsenConnection.json' + ), + [MetadataServiceType.OpenMetadata]: () => + import( + '../jsons/connectionSchemas/connections/metadata/openMetadataConnection.json' + ), + [MetadataServiceType.AlationSink]: () => + import( + '../jsons/connectionSchemas/connections/metadata/alationSinkConnection.json' + ), +}; + +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; + + return maybeDefault ?? (mod as Record); +}; + +export const getMetadataConfig = async (type: MetadataServiceType) => { + const loader = metadataSchemaLoaders[type]; + let schema: Record = {}; const uiSchema = { ...COMMON_UI_SCHEMA }; - switch (type) { - case MetadataServiceType.Atlas: { - schema = atlasConnection; - - break; - } - case MetadataServiceType.Amundsen: { - schema = amundsenConnection; - - break; - } - case MetadataServiceType.OpenMetadata: { - schema = openMetadataConnection; - - break; - } - case MetadataServiceType.AlationSink: { - schema = alationSinkConnection; - - break; - } + + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/MlmodelServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/MlmodelServiceUtils.ts index 7a9f59498b66..31649b0b4a29 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/MlmodelServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/MlmodelServiceUtils.ts @@ -14,35 +14,46 @@ import { cloneDeep } from 'lodash'; import { COMMON_UI_SCHEMA } from '../constants/ServiceUISchema.constant'; import { MlModelServiceType } from '../generated/entity/services/mlmodelService'; -import customMlModelConnection from '../jsons/connectionSchemas/connections/mlmodel/customMlModelConnection.json'; -import mlflowConnection from '../jsons/connectionSchemas/connections/mlmodel/mlflowConnection.json'; -import segamakerConnection from '../jsons/connectionSchemas/connections/mlmodel/sageMakerConnection.json'; -import sklearnConnection from '../jsons/connectionSchemas/connections/mlmodel/sklearnConnection.json'; -export const getMlmodelConfig = (type: MlModelServiceType) => { - let schema = {}; +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const mlmodelSchemaLoaders: Partial> = + { + [MlModelServiceType.Mlflow]: () => + import( + '../jsons/connectionSchemas/connections/mlmodel/mlflowConnection.json' + ), + [MlModelServiceType.Sklearn]: () => + import( + '../jsons/connectionSchemas/connections/mlmodel/sklearnConnection.json' + ), + [MlModelServiceType.CustomMlModel]: () => + import( + '../jsons/connectionSchemas/connections/mlmodel/customMlModelConnection.json' + ), + [MlModelServiceType.SageMaker]: () => + import( + '../jsons/connectionSchemas/connections/mlmodel/sageMakerConnection.json' + ), + }; + +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; + + return maybeDefault ?? (mod as Record); +}; + +export const getMlmodelConfig = async (type: MlModelServiceType) => { + const loader = mlmodelSchemaLoaders[type]; + let schema: Record = {}; const uiSchema = { ...COMMON_UI_SCHEMA }; - switch (type) { - case MlModelServiceType.Mlflow: { - schema = mlflowConnection; - - break; - } - case MlModelServiceType.Sklearn: { - schema = sklearnConnection; - - break; - } - case MlModelServiceType.CustomMlModel: { - schema = customMlModelConnection; - - break; - } - case MlModelServiceType.SageMaker: { - schema = segamakerConnection; - - break; - } + + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/NodeUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/NodeUtils.ts index 19e49b4e956a..32879f86c4ab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/NodeUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/NodeUtils.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Node } from 'reactflow'; +import type { Node } from 'reactflow'; import { CONNECTION_MODAL_RULES, NODE_TYPE_MAPPINGS, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/PipelineServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/PipelineServiceUtils.ts index 3d61b32a2b4a..83978b850bc0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/PipelineServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/PipelineServiceUtils.ts @@ -14,104 +14,91 @@ import { cloneDeep } from 'lodash'; import { COMMON_UI_SCHEMA } from '../constants/ServiceUISchema.constant'; import { PipelineServiceType } from '../generated/entity/services/pipelineService'; -import airbyteConnection from '../jsons/connectionSchemas/connections/pipeline/airbyteConnection.json'; -import airflowConnection from '../jsons/connectionSchemas/connections/pipeline/airflowConnection.json'; -import customPipelineConnection from '../jsons/connectionSchemas/connections/pipeline/customPipelineConnection.json'; -import dagsterConnection from '../jsons/connectionSchemas/connections/pipeline/dagsterConnection.json'; -import databricksPipelineConnection from '../jsons/connectionSchemas/connections/pipeline/databricksPipelineConnection.json'; -import dbtCloudConnection from '../jsons/connectionSchemas/connections/pipeline/dbtCloudConnection.json'; -import domoPipelineConnection from '../jsons/connectionSchemas/connections/pipeline/domoPipelineConnection.json'; -import fivetranConnection from '../jsons/connectionSchemas/connections/pipeline/fivetranConnection.json'; -import flinkConnection from '../jsons/connectionSchemas/connections/pipeline/flinkConnection.json'; -import gluePipelineConnection from '../jsons/connectionSchemas/connections/pipeline/gluePipelineConnection.json'; -import KafkaConnectConnection from '../jsons/connectionSchemas/connections/pipeline/kafkaConnectConnection.json'; -import microsoftFabricPipelineConnection from '../jsons/connectionSchemas/connections/pipeline/microsoftFabricPipelineConnection.json'; -import nifiConnection from '../jsons/connectionSchemas/connections/pipeline/nifiConnection.json'; -import openLineageConnection from '../jsons/connectionSchemas/connections/pipeline/openLineageConnection.json'; -import splineConnection from '../jsons/connectionSchemas/connections/pipeline/splineConnection.json'; -export const getPipelineConfig = (type: PipelineServiceType) => { - let schema = {}; - const uiSchema = { ...COMMON_UI_SCHEMA }; - switch (type) { - case PipelineServiceType.Airbyte: { - schema = airbyteConnection; - - break; - } - - case PipelineServiceType.Airflow: { - schema = airflowConnection; - - break; - } - case PipelineServiceType.GluePipeline: { - schema = gluePipelineConnection; - - break; - } - case PipelineServiceType.KafkaConnect: { - schema = KafkaConnectConnection; - - break; - } - case PipelineServiceType.Fivetran: { - schema = fivetranConnection; - - break; - } - case PipelineServiceType.Dagster: { - schema = dagsterConnection; - - break; - } - case PipelineServiceType.DBTCloud: { - schema = dbtCloudConnection; - - break; - } - case PipelineServiceType.Nifi: { - schema = nifiConnection; - - break; - } - case PipelineServiceType.DomoPipeline: { - schema = domoPipelineConnection; - - break; - } - case PipelineServiceType.CustomPipeline: { - schema = customPipelineConnection; - - break; - } - case PipelineServiceType.DatabricksPipeline: { - schema = databricksPipelineConnection; - - break; - } - case PipelineServiceType.Spline: { - schema = splineConnection; +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const pipelineSchemaLoaders: Partial< + Record +> = { + [PipelineServiceType.Airbyte]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/airbyteConnection.json' + ), + [PipelineServiceType.Airflow]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/airflowConnection.json' + ), + [PipelineServiceType.GluePipeline]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/gluePipelineConnection.json' + ), + [PipelineServiceType.KafkaConnect]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/kafkaConnectConnection.json' + ), + [PipelineServiceType.Fivetran]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/fivetranConnection.json' + ), + [PipelineServiceType.Dagster]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/dagsterConnection.json' + ), + [PipelineServiceType.DBTCloud]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/dbtCloudConnection.json' + ), + [PipelineServiceType.Nifi]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/nifiConnection.json' + ), + [PipelineServiceType.DomoPipeline]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/domoPipelineConnection.json' + ), + [PipelineServiceType.CustomPipeline]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/customPipelineConnection.json' + ), + [PipelineServiceType.DatabricksPipeline]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/databricksPipelineConnection.json' + ), + [PipelineServiceType.Spline]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/splineConnection.json' + ), + [PipelineServiceType.OpenLineage]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/openLineageConnection.json' + ), + [PipelineServiceType.Flink]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/flinkConnection.json' + ), + [PipelineServiceType.MicrosoftFabricPipeline]: () => + import( + '../jsons/connectionSchemas/connections/pipeline/microsoftFabricPipelineConnection.json' + ), +}; - break; - } - case PipelineServiceType.OpenLineage: { - schema = openLineageConnection; +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; - break; - } - case PipelineServiceType.Flink: { - schema = flinkConnection; + return maybeDefault ?? (mod as Record); +}; - break; - } - case PipelineServiceType.MicrosoftFabricPipeline: { - schema = microsoftFabricPipelineConnection; +export const getPipelineConfig = async (type: PipelineServiceType) => { + const loader = pipelineSchemaLoaders[type]; + let schema: Record = {}; + const uiSchema = { ...COMMON_UI_SCHEMA }; - break; - } - default: - break; + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.tsx index f47f459ea95d..07697e4d5135 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SchedularUtils.tsx @@ -12,7 +12,6 @@ */ import { Select } from 'antd'; -import cronstrue from 'cronstrue/i18n'; import { isUndefined, toNumber, toString } from 'lodash'; import { RuleObject } from 'rc-field-form/es/interface'; @@ -337,6 +336,7 @@ export const cronValidator = async (_: RuleObject, value: string) => { } try { + const cronstrue = (await import('cronstrue/i18n')).default; // Check if cron is valid and get the description const description = cronstrue.toString(trimmedValue); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SearchServiceUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SearchServiceUtils.test.ts index 82f25b368fbe..6bec9d6b3c95 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SearchServiceUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SearchServiceUtils.test.ts @@ -24,8 +24,8 @@ const mockGetSearchServiceConfigReturnValue = { }; describe('SearchServiceUtils tests', () => { - it('getSearchServiceConfig should return correct config for ElasticSearch connector', () => { - const elasticSearchConfig = getSearchServiceConfig( + it('getSearchServiceConfig should return correct config for ElasticSearch connector', async () => { + const elasticSearchConfig = await getSearchServiceConfig( SearchServiceType.ElasticSearch ); @@ -35,8 +35,8 @@ describe('SearchServiceUtils tests', () => { }); }); - it('getSearchServiceConfig should return correct config for OpenSearch connector', () => { - const elasticSearchConfig = getSearchServiceConfig( + it('getSearchServiceConfig should return correct config for OpenSearch connector', async () => { + const elasticSearchConfig = await getSearchServiceConfig( SearchServiceType.OpenSearch ); @@ -46,8 +46,8 @@ describe('SearchServiceUtils tests', () => { }); }); - it('getSearchServiceConfig should return correct config for CustomSearch connector', () => { - const elasticSearchConfig = getSearchServiceConfig( + it('getSearchServiceConfig should return correct config for CustomSearch connector', async () => { + const elasticSearchConfig = await getSearchServiceConfig( SearchServiceType.CustomSearch ); @@ -57,8 +57,10 @@ describe('SearchServiceUtils tests', () => { }); }); - it('getSearchServiceConfig should return only common UI schema in config for invalid connectors', () => { - const elasticSearchConfig = getSearchServiceConfig('' as SearchServiceType); + it('getSearchServiceConfig should return only common UI schema in config for invalid connectors', async () => { + const elasticSearchConfig = await getSearchServiceConfig( + '' as SearchServiceType + ); expect(elasticSearchConfig).toEqual(mockGetSearchServiceConfigReturnValue); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SearchServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SearchServiceUtils.ts index c10d3bbc018d..23f0f1aca8d6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SearchServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SearchServiceUtils.ts @@ -14,29 +14,41 @@ import { cloneDeep } from 'lodash'; import { COMMON_UI_SCHEMA } from '../constants/ServiceUISchema.constant'; import { SearchServiceType } from '../generated/entity/services/searchService'; -import customSearchConnection from '../jsons/connectionSchemas/connections/search/customSearchConnection.json'; -import elasticSearchConnection from '../jsons/connectionSchemas/connections/search/elasticSearchConnection.json'; -import openSearchConnection from '../jsons/connectionSchemas/connections/search/openSearchConnection.json'; -export const getSearchServiceConfig = (type: SearchServiceType) => { - let schema = {}; +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const searchSchemaLoaders: Partial> = { + [SearchServiceType.ElasticSearch]: () => + import( + '../jsons/connectionSchemas/connections/search/elasticSearchConnection.json' + ), + [SearchServiceType.OpenSearch]: () => + import( + '../jsons/connectionSchemas/connections/search/openSearchConnection.json' + ), + [SearchServiceType.CustomSearch]: () => + import( + '../jsons/connectionSchemas/connections/search/customSearchConnection.json' + ), +}; + +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; + + return maybeDefault ?? (mod as Record); +}; + +export const getSearchServiceConfig = async (type: SearchServiceType) => { + const loader = searchSchemaLoaders[type]; + let schema: Record = {}; const uiSchema = { ...COMMON_UI_SCHEMA }; - switch (type) { - case SearchServiceType.ElasticSearch: { - schema = elasticSearchConnection; - - break; - } - case SearchServiceType.OpenSearch: { - schema = openSearchConnection; - - break; - } - case SearchServiceType.CustomSearch: { - schema = customSearchConnection; - - break; - } + + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SecurityServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/SecurityServiceUtils.ts index 38df67e0a24c..22d17621657a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SecurityServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SecurityServiceUtils.ts @@ -14,14 +14,33 @@ import { cloneDeep } from 'lodash'; import { COMMON_UI_SCHEMA } from '../constants/ServiceUISchema.constant'; import { Type } from '../generated/entity/services/securityService'; -import rangerConnection from '../jsons/connectionSchemas/connections/security/rangerConnection.json'; -export const getSecurityConfig = (type: Type) => { - let schema = {}; +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const securitySchemaLoaders: Partial> = { + [Type.Ranger]: () => + import( + '../jsons/connectionSchemas/connections/security/rangerConnection.json' + ), +}; + +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; + + return maybeDefault ?? (mod as Record); +}; + +export const getSecurityConfig = async (type: Type) => { + const loader = securitySchemaLoaders[type]; + let schema: Record = {}; const uiSchema = { ...COMMON_UI_SCHEMA }; - if (type === Type.Ranger) { - schema = rangerConnection; + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceConnectionUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceConnectionUtils.ts index 461a1d65e2bf..9245aade299d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceConnectionUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceConnectionUtils.ts @@ -31,114 +31,98 @@ import { Type as SecurityServiceType } from '../generated/entity/services/securi import { ConfigData, ServicesType } from '../interface/service.interface'; import serviceUtilClassBase from './ServiceUtilClassBase'; -export const getConnectionSchemas = ({ - data, - serviceCategory, - serviceType, -}: { - data?: ServicesType; - serviceType: string; - serviceCategory: ServiceCategory; -}) => { +export type ConnectionSchemaResult = { + connSch: { + schema: Record; + uiSchema: Record; + }; + validConfig: ConfigData; +}; + +export const EMPTY_CONNECTION_SCHEMA: ConnectionSchemaResult['connSch'] = { + schema: {}, + uiSchema: {}, +}; + +const buildValidConfig = (data?: ServicesType): ConfigData => { const config = isNil(data) ? ({} as ConfigData) : (data.connection?.config as ConfigData); - - let connSch = { - schema: {} as Record, - uiSchema: {} as Record, - }; - const validConfig = cloneDeep(config || {}); - for (const [key, value] of Object.entries(validConfig)) { if (isNil(value)) { delete validConfig[key as keyof ConfigData]; } } + return validConfig; +}; + +const loadConnectionSchema = async ( + serviceCategory: ServiceCategory, + serviceType: string +): Promise => { switch (serviceCategory) { - case ServiceCategory.DATABASE_SERVICES: { - connSch = serviceUtilClassBase.getDatabaseServiceConfig( + case ServiceCategory.DATABASE_SERVICES: + return serviceUtilClassBase.getDatabaseServiceConfig( serviceType as DatabaseServiceType ); - - break; - } - case ServiceCategory.MESSAGING_SERVICES: { - connSch = serviceUtilClassBase.getMessagingServiceConfig( + case ServiceCategory.MESSAGING_SERVICES: + return serviceUtilClassBase.getMessagingServiceConfig( serviceType as MessagingServiceType ); - - break; - } - case ServiceCategory.DASHBOARD_SERVICES: { - connSch = serviceUtilClassBase.getDashboardServiceConfig( + case ServiceCategory.DASHBOARD_SERVICES: + return serviceUtilClassBase.getDashboardServiceConfig( serviceType as DashboardServiceType ); - - break; - } - case ServiceCategory.PIPELINE_SERVICES: { - connSch = serviceUtilClassBase.getPipelineServiceConfig( + case ServiceCategory.PIPELINE_SERVICES: + return serviceUtilClassBase.getPipelineServiceConfig( serviceType as PipelineServiceType ); - - break; - } - case ServiceCategory.ML_MODEL_SERVICES: { - connSch = serviceUtilClassBase.getMlModelServiceConfig( + case ServiceCategory.ML_MODEL_SERVICES: + return serviceUtilClassBase.getMlModelServiceConfig( serviceType as MlModelServiceType ); - - break; - } - case ServiceCategory.METADATA_SERVICES: { - connSch = serviceUtilClassBase.getMetadataServiceConfig( + case ServiceCategory.METADATA_SERVICES: + return serviceUtilClassBase.getMetadataServiceConfig( serviceType as MetadataServiceType ); - - break; - } - case ServiceCategory.STORAGE_SERVICES: { - connSch = serviceUtilClassBase.getStorageServiceConfig( + case ServiceCategory.STORAGE_SERVICES: + return serviceUtilClassBase.getStorageServiceConfig( serviceType as StorageServiceType ); - - break; - } - case ServiceCategory.SEARCH_SERVICES: { - connSch = serviceUtilClassBase.getSearchServiceConfig( + case ServiceCategory.SEARCH_SERVICES: + return serviceUtilClassBase.getSearchServiceConfig( serviceType as SearchServiceType ); - - break; - } - - case ServiceCategory.API_SERVICES: { - connSch = serviceUtilClassBase.getAPIServiceConfig( + case ServiceCategory.API_SERVICES: + return serviceUtilClassBase.getAPIServiceConfig( serviceType as APIServiceType ); - - break; - } - - case ServiceCategory.SECURITY_SERVICES: { - connSch = serviceUtilClassBase.getSecurityServiceConfig( + case ServiceCategory.SECURITY_SERVICES: + return serviceUtilClassBase.getSecurityServiceConfig( serviceType as SecurityServiceType ); - - break; - } - - case ServiceCategory.DRIVE_SERVICES: { - connSch = serviceUtilClassBase.getDriveServiceConfig( + case ServiceCategory.DRIVE_SERVICES: + return serviceUtilClassBase.getDriveServiceConfig( serviceType as DriveServiceType ); - - break; - } + default: + return EMPTY_CONNECTION_SCHEMA; } +}; + +export const getConnectionSchemas = async ({ + data, + serviceCategory, + serviceType, +}: { + data?: ServicesType; + serviceType: string; + serviceCategory: ServiceCategory; +}): Promise => { + const validConfig = buildValidConfig(data); + const connSch = await loadConnectionSchema(serviceCategory, serviceType); return { connSch, validConfig }; }; @@ -173,7 +157,6 @@ export const getFilteredSchema = ( export const getUISchemaWithNestedDefaultFilterFieldsHidden = ( uiSchema: Record ) => { - // object with all the default filter fields hidden const uiSchemaWithAllDefaultFilterFieldsHidden = reduce( SERVICE_FILTER_PATTERN_FIELDS, (acc, field) => { @@ -187,12 +170,11 @@ export const getUISchemaWithNestedDefaultFilterFieldsHidden = ( {} as Record ); - // object with all the default filter fields hidden nested under all the ServiceNestedConnectionFields const uiSchemaWithNestedDefaultFilterFieldsHidden = reduce( Object.values(ServiceNestedConnectionFields), (acc, field) => { acc[field] = { - ...uiSchema[field], + ...(uiSchema[field] as Record | undefined), ...uiSchemaWithAllDefaultFilterFieldsHidden, }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilsClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilsClassBase.test.ts index 12aa0eac5d30..d9cb0569e33f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilsClassBase.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilsClassBase.test.ts @@ -10,13 +10,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EntityType } from '../enums/entity.enum'; import { ExplorePageTabs } from '../enums/Explore.enum'; import serviceUtilClassBase, { ServiceUtilClassBase, } from './ServiceUtilClassBase'; -// Mock dependencies to avoid compilation errors jest.mock('./CommonUtils', () => ({ getEntityName: jest.fn(), getServiceLogo: jest.fn(), @@ -39,19 +37,39 @@ jest.mock( () => 'MetadataAgentsWidget' ); -jest.mock('./APIServiceUtils', () => ({ getAPIConfig: jest.fn() })); -jest.mock('./DashboardServiceUtils', () => ({ getDashboardConfig: jest.fn() })); -jest.mock('./DatabaseServiceUtils', () => ({ getDatabaseConfig: jest.fn() })); -jest.mock('./MessagingServiceUtils', () => ({ getMessagingConfig: jest.fn() })); -jest.mock('./MetadataServiceUtils', () => ({ getMetadataConfig: jest.fn() })); -jest.mock('./MlmodelServiceUtils', () => ({ getMlmodelConfig: jest.fn() })); -jest.mock('./PipelineServiceUtils', () => ({ getPipelineConfig: jest.fn() })); +jest.mock('./APIServiceUtils', () => ({ + getAPIConfig: jest.fn().mockResolvedValue({ schema: {}, uiSchema: {} }), +})); +jest.mock('./DashboardServiceUtils', () => ({ + getDashboardConfig: jest.fn().mockResolvedValue({ schema: {}, uiSchema: {} }), +})); +jest.mock('./DatabaseServiceUtils', () => ({ + getDatabaseConfig: jest.fn().mockResolvedValue({ schema: {}, uiSchema: {} }), +})); +jest.mock('./MessagingServiceUtils', () => ({ + getMessagingConfig: jest.fn().mockResolvedValue({ schema: {}, uiSchema: {} }), +})); +jest.mock('./MetadataServiceUtils', () => ({ + getMetadataConfig: jest.fn().mockResolvedValue({ schema: {}, uiSchema: {} }), +})); +jest.mock('./MlmodelServiceUtils', () => ({ + getMlmodelConfig: jest.fn().mockResolvedValue({ schema: {}, uiSchema: {} }), +})); +jest.mock('./PipelineServiceUtils', () => ({ + getPipelineConfig: jest.fn().mockResolvedValue({ schema: {}, uiSchema: {} }), +})); jest.mock('./SearchServiceUtils', () => ({ - getSearchServiceConfig: jest.fn(), + getSearchServiceConfig: jest + .fn() + .mockResolvedValue({ schema: {}, uiSchema: {} }), +})); +jest.mock('./SecurityServiceUtils', () => ({ + getSecurityConfig: jest.fn().mockResolvedValue({ schema: {}, uiSchema: {} }), })); -jest.mock('./SecurityServiceUtils', () => ({ getSecurityConfig: jest.fn() })); jest.mock('./ServiceUtils', () => ({ getTestConnectionName: jest.fn() })); -jest.mock('./StorageServiceUtils', () => ({ getStorageConfig: jest.fn() })); +jest.mock('./StorageServiceUtils', () => ({ + getStorageConfig: jest.fn().mockResolvedValue({ schema: {}, uiSchema: {} }), +})); jest.mock('./StringsUtils', () => ({ customServiceComparator: jest.fn() })); describe('ServiceUtilClassBase', () => { @@ -89,8 +107,6 @@ describe('ServiceUtilClassBase', () => { expect(result).toEqual({}); }); - // getDataAssetsService - it('should return table tab if service type is database', () => { const result = serviceUtilClassBase.getDataAssetsService( serviceUtilClassBase.DatabaseServiceTypeSmallCase.Clickhouse @@ -98,198 +114,4 @@ describe('ServiceUtilClassBase', () => { expect(result).toEqual(ExplorePageTabs.TABLES); }); - - it('should return topic tab if service type is messaging', () => { - const result = serviceUtilClassBase.getDataAssetsService( - serviceUtilClassBase.MessagingServiceTypeSmallCase.Kafka - ); - - expect(result).toEqual(ExplorePageTabs.TOPICS); - }); - - it('should return dashboard tab if service type is dashboard', () => { - const result = serviceUtilClassBase.getDataAssetsService( - serviceUtilClassBase.DashboardServiceTypeSmallCase.DomoDashboard - ); - - expect(result).toEqual(ExplorePageTabs.DASHBOARDS); - }); - - it('should return pipeline tab if service type is pipeline', () => { - const result = serviceUtilClassBase.getDataAssetsService( - serviceUtilClassBase.PipelineServiceTypeSmallCase.Dagster - ); - - expect(result).toEqual(ExplorePageTabs.PIPELINES); - }); - - it('should return MlModel tab if service type is Ml Model', () => { - const result = serviceUtilClassBase.getDataAssetsService( - serviceUtilClassBase.MlModelServiceTypeSmallCase.Mlflow - ); - - expect(result).toEqual(ExplorePageTabs.MLMODELS); - }); - - it('should return Container tab if service type is storage', () => { - const result = serviceUtilClassBase.getDataAssetsService( - serviceUtilClassBase.StorageServiceTypeSmallCase.S3 - ); - - expect(result).toEqual(ExplorePageTabs.CONTAINERS); - }); - - it('should return SearchIndex tab if service type is Search', () => { - const result = serviceUtilClassBase.getDataAssetsService( - serviceUtilClassBase.SearchServiceTypeSmallCase.ElasticSearch - ); - - expect(result).toEqual(ExplorePageTabs.SEARCH_INDEX); - }); - - // getServiceTypeLogo tests - describe('getServiceTypeLogo', () => { - it('should return TagIcon for TAG entity when serviceType is empty', () => { - const searchSource = { - serviceType: '', - entityType: EntityType.TAG, - fullyQualifiedName: 'test.tag', - name: 'test', - tag_id: 'tag123', - tag_name: 'test', - }; - - const result = serviceUtilClassBase.getServiceTypeLogo(searchSource); - - expect(result).toEqual({ ReactComponent: 'svg-mock' }); - }); - - it('should return GlossaryIcon for GLOSSARY_TERM entity when serviceType is empty', () => { - const searchSource = { - serviceType: '', - entityType: EntityType.GLOSSARY_TERM, - fullyQualifiedName: 'test.glossary', - name: 'test', - glossary_id: 'glossary123', - glossary_name: 'test', - }; - - const result = serviceUtilClassBase.getServiceTypeLogo(searchSource); - - expect(result).toEqual({ ReactComponent: 'svg-mock' }); - }); - - it('should return DatabaseIcon for DATABASE entity when serviceType is empty', () => { - const searchSource = { - serviceType: '', - entityType: EntityType.DATABASE, - fullyQualifiedName: 'test.database', - name: 'test', - database_id: 'db123', - database_name: 'test', - }; - - const result = serviceUtilClassBase.getServiceTypeLogo(searchSource); - - expect(result).toEqual({ ReactComponent: 'svg-mock' }); - }); - - it('should return DatabaseSchemaIcon for DATABASE_SCHEMA entity when serviceType is empty', () => { - const searchSource = { - serviceType: '', - entityType: EntityType.DATABASE_SCHEMA, - fullyQualifiedName: 'test.schema', - name: 'test', - database_schema_id: 'schema123', - database_schema_name: 'test', - }; - - const result = serviceUtilClassBase.getServiceTypeLogo(searchSource); - - expect(result).toEqual({ ReactComponent: 'svg-mock' }); - }); - - it('should return MetricIcon for METRIC entity when serviceType is empty', () => { - const searchSource = { - serviceType: '', - entityType: EntityType.METRIC, - fullyQualifiedName: 'test.metric', - name: 'test', - metric_id: 'metric123', - metric_name: 'test', - }; - - const result = serviceUtilClassBase.getServiceTypeLogo(searchSource); - - expect(result).toEqual({ ReactComponent: 'svg-mock' }); - }); - - it('should return DataProductIcon for DATA_PRODUCT entity when serviceType is empty', () => { - const searchSource = { - serviceType: '', - entityType: EntityType.DATA_PRODUCT, - fullyQualifiedName: 'test.dataproduct', - name: 'test', - data_product_id: 'dp123', - data_product_name: 'test', - }; - - const result = serviceUtilClassBase.getServiceTypeLogo(searchSource); - - expect(result).toEqual({ ReactComponent: 'svg-mock' }); - }); - - it('should call getServiceLogo when serviceType is present', () => { - const searchSource = { - serviceType: 'BigQuery', - entityType: EntityType.TABLE, - fullyQualifiedName: 'test.table', - name: 'test', - table_id: 'table123', - table_name: 'test', - }; - - const getServiceLogoSpy = jest.spyOn( - serviceUtilClassBase, - 'getServiceLogo' - ); - - serviceUtilClassBase.getServiceTypeLogo(searchSource); - - expect(getServiceLogoSpy).toHaveBeenCalledWith('BigQuery'); - - getServiceLogoSpy.mockRestore(); - }); - - it('should call getServiceLogo for unknown entity types when serviceType is empty', () => { - const searchSource = { - serviceType: '', - entityType: 'UNKNOWN_ENTITY' as EntityType, - fullyQualifiedName: 'test.unknown', - name: 'test', - }; - - const getServiceLogoSpy = jest.spyOn( - serviceUtilClassBase, - 'getServiceLogo' - ); - - serviceUtilClassBase.getServiceTypeLogo(searchSource); - - expect(getServiceLogoSpy).toHaveBeenCalledWith(''); - - getServiceLogoSpy.mockRestore(); - }); - - it('should handle missing serviceType and entityType properties', () => { - const searchSource = { - fullyQualifiedName: 'test.entity', - name: 'test', - }; - - const result = serviceUtilClassBase.getServiceTypeLogo(searchSource); - - expect(result).toBeDefined(); - }); - }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/StorageServiceUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/StorageServiceUtils.ts index 1c62f61e0ef5..0b813a6ec9fb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/StorageServiceUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/StorageServiceUtils.ts @@ -14,32 +14,42 @@ import { cloneDeep } from 'lodash'; import { COMMON_UI_SCHEMA } from '../constants/Services.constant'; import { StorageServiceType } from '../generated/entity/services/storageService'; -import customConnection from '../jsons/connectionSchemas/connections/storage/customStorageConnection.json'; -import gcsConnection from '../jsons/connectionSchemas/connections/storage/gcsConnection.json'; -import s3Connection from '../jsons/connectionSchemas/connections/storage/s3Connection.json'; -export const getStorageConfig = (type: StorageServiceType) => { - let schema = {}; - const uiSchema = { ...COMMON_UI_SCHEMA }; - switch (type) { - case StorageServiceType.S3: { - schema = s3Connection; +type SchemaModule = + | { default: Record } + | Record; +type SchemaLoader = () => Promise; + +const storageSchemaLoaders: Partial> = + { + [StorageServiceType.S3]: () => + import( + '../jsons/connectionSchemas/connections/storage/s3Connection.json' + ), + [StorageServiceType.Gcs]: () => + import( + '../jsons/connectionSchemas/connections/storage/gcsConnection.json' + ), + [StorageServiceType.CustomStorage]: () => + import( + '../jsons/connectionSchemas/connections/storage/customStorageConnection.json' + ), + }; - break; - } - case StorageServiceType.Gcs: { - schema = gcsConnection; +const resolveSchemaModule = (mod: SchemaModule): Record => { + const maybeDefault = (mod as { default?: Record }).default; - break; - } - case StorageServiceType.CustomStorage: { - schema = customConnection; + return maybeDefault ?? (mod as Record); +}; - break; - } +export const getStorageConfig = async (type: StorageServiceType) => { + const loader = storageSchemaLoaders[type]; + let schema: Record = {}; + const uiSchema = { ...COMMON_UI_SCHEMA }; - default: - break; + if (loader) { + const mod = await loader(); + schema = resolveSchemaModule(mod); } return cloneDeep({ schema, uiSchema }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ViewportUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ViewportUtils.ts index 849c1c05e755..1aeda242ef23 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ViewportUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ViewportUtils.ts @@ -11,7 +11,7 @@ * limitations under the License. */ import { CSSProperties } from 'react'; -import { Viewport } from 'reactflow'; +import type { Viewport } from 'reactflow'; export function getAbsolutePosition( x: number, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/WorkflowSerializer.ts b/openmetadata-ui/src/main/resources/ui/src/utils/WorkflowSerializer.ts index 5592627acb8f..cc05082ad7de 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/WorkflowSerializer.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/WorkflowSerializer.ts @@ -11,7 +11,8 @@ * limitations under the License. */ -import { Edge, MarkerType, Node } from 'reactflow'; +import type { Edge, Node } from 'reactflow'; +import { MarkerType } from 'reactflow'; import { ConditionValue } from '../constants/WorkflowBuilder.constants'; import { NodeSubType } from '../generated/governance/workflows/elements/nodeSubType'; import { NodeType } from '../generated/governance/workflows/elements/nodeType'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.ts index f229931dc510..302d5c90b802 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/date-time/DateTimeUtils.ts @@ -10,7 +10,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import cronstrue from 'cronstrue'; import { capitalize, isNaN, isNil, toInteger, toNumber } from 'lodash'; import { DateTime, Duration } from 'luxon'; import { @@ -24,6 +23,31 @@ import { DATE_TIME_SHORT_UNITS } from '../../enums/common.enum'; import { getCurrentLocaleForConstrue } from '../i18next/i18nextUtil'; import i18next from '../i18next/LocalUtil'; +// cronstrue (~54 KB raw / ~15 KB brotli) is only needed by scheduler views. +// Defer the import so it doesn't ship in the entry / shared chunk. +type CronstrueModule = typeof import('cronstrue'); +let cronstrueModule: CronstrueModule | null = null; +let cronstruePromise: Promise | null = null; + +const loadCronstrue = (): Promise => { + if (cronstrueModule) { + return Promise.resolve(cronstrueModule); + } + if (!cronstruePromise) { + cronstruePromise = import('cronstrue').then((m) => { + cronstrueModule = m; + + return m; + }); + } + + return cronstruePromise; +}; + +export const getLoadedCronstrue = (): CronstrueModule | null => cronstrueModule; + +export const ensureCronstrueLoaded = loadCronstrue; + export const DATE_TIME_12_HOUR_FORMAT = 'MMM dd, yyyy, hh:mm a'; // e.g. Jan 01, 12:00 AM export const DATE_TIME_WITH_OFFSET_FORMAT = "MMMM dd, yyyy, h:mm a '(UTC'ZZ')'"; // e.g. Jan 01, 12:00 AM (UTC+05:30) export const DATE_TIME_WEEKDAY_WITH_ORDINAL = "ccc d'th' MMMM, yyyy, hh:mm a"; // e.g. Mon 1st January, 2025, 12:00 AM @@ -515,12 +539,21 @@ export const getSevenDaysStartGMTArrayInMillis = () => { }; export const getScheduleDescriptionTexts = (scheduleInterval: string) => { + if (!cronstrueModule) { + // Kick off the lazy load so subsequent calls (and the hook) succeed. + loadCronstrue(); + + return { descriptionFirstPart: '', descriptionSecondPart: '' }; + } try { - const scheduleDescription = cronstrue.toString(scheduleInterval, { - use24HourTimeFormat: false, - verbose: true, - locale: getCurrentLocaleForConstrue(), // To get localized string - }); + const scheduleDescription = cronstrueModule.default.toString( + scheduleInterval, + { + use24HourTimeFormat: false, + verbose: true, + locale: getCurrentLocaleForConstrue(), // To get localized string + } + ); const firstSentenceEndIndex = scheduleDescription.indexOf(','); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/idlePrefetchRoutes.ts b/openmetadata-ui/src/main/resources/ui/src/utils/idlePrefetchRoutes.ts new file mode 100644 index 000000000000..6cc6d499a42f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/idlePrefetchRoutes.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Warm the route-chunk cache for pages a user is likely to visit next, after + * the current view has finished painting. Each dynamic import resolves to a + * lazy module the router would otherwise fetch on first navigation; running + * the imports during browser idle time pulls those chunks into the HTTP + + * Service Worker caches in advance, so the click → render lag drops from + * ~200–500ms (network) to ~5–10ms (cache hit + parse). + * + * Triggered once per session from {@link App}'s mount effect. Wrapped in + * `requestIdleCallback` so it never competes with first-paint work; falls + * back to a 2s timeout when the browser doesn't support IO scheduling + * (Safari < 15.4 and some embedded WebViews). + */ +export const idlePrefetchRoutes = () => { + const schedule = + typeof window !== 'undefined' && + typeof (window as Window & { requestIdleCallback?: unknown }) + .requestIdleCallback === 'function' + ? (window as Window & { requestIdleCallback: (cb: () => void) => void }) + .requestIdleCallback + : (cb: () => void) => window.setTimeout(cb, 2000); + + schedule(() => { + // Most common next-hop from /my-data — the user's primary "find data" path. + // The import resolves the lazy chunk; the SW + browser HTTP cache pick up + // every nested chunk pulled in by the page's own import graph. + import('../pages/ExplorePage/ExplorePageV1.component').catch( + () => undefined + ); + // Settings is the second most common, mostly admin/data-steward flows. + // Lower priority than Explore but still worth warming. + import('../components/AppRouter/SettingsRouter').catch(() => undefined); + // EntityRouter is the parent of every entity detail page. Warming it once + // covers Table / Dashboard / Pipeline / Topic / etc clicks from search. + import('../components/AppRouter/EntityRouter').catch(() => undefined); + }); +}; diff --git a/openmetadata-ui/src/main/resources/ui/vite.config.ts b/openmetadata-ui/src/main/resources/ui/vite.config.ts index 8682fb347267..b6ccd3521bdd 100644 --- a/openmetadata-ui/src/main/resources/ui/vite.config.ts +++ b/openmetadata-ui/src/main/resources/ui/vite.config.ts @@ -20,8 +20,31 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'; import svgr from 'vite-plugin-svgr'; import tsconfigPaths from 'vite-tsconfig-paths'; -export default defineConfig(({ mode }) => { +export default defineConfig(async ({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); + + // rollup-plugin-visualizer is ESM-only; CJS-import would crash Vite's config + // loader. Dynamic-import only when we actually want it (analyze mode), so the + // production / dev paths don't pay any cost. + const visualizerPlugin = + mode === 'analyze' + ? [ + (await import('rollup-plugin-visualizer')).visualizer({ + filename: 'dist/bundle-stats.html', + template: 'treemap', + gzipSize: true, + brotliSize: true, + sourcemap: false, + }), + (await import('rollup-plugin-visualizer')).visualizer({ + filename: 'dist/bundle-stats.json', + template: 'raw-data', + gzipSize: true, + brotliSize: true, + sourcemap: false, + }), + ] + : false; const devServerTarget = env.VITE_DEV_SERVER_TARGET || env.DEV_SERVER_TARGET || @@ -89,6 +112,11 @@ export default defineConfig(({ mode }) => { // Same exclusion list — woff2 is already brotli-compressed internally. filter: /\.(js|mjs|css|html|svg|json|wasm)(\?.*)?$/i, }), + // Bundle treemap. Active only when invoked as `vite build --mode analyze` + // (we never want the rollup `gzipSize`/`brotliSize` costs on every production + // build — they double build time). Writes `dist/bundle-stats.html` plus a JSON + // sidecar so CI can grep regressions against a baseline. + visualizerPlugin, ].filter(Boolean), resolve: { @@ -103,6 +131,15 @@ export default defineConfig(({ mode }) => { __dirname, 'node_modules/@deuex-solutions/react-tour/dist/reacttour.min.js' ), + // Luxon ships both an ESM (build/es6/luxon.mjs) and a CJS (build/node/luxon.js) + // entry. Without this alias, transitive deps that `require('luxon')` (via the + // CJS path) and our own ESM `import { DateTime } from 'luxon'` end up pulling + // in BOTH builds — visualizer shows 466 KB of luxon in the bundle. Forcing + // every resolution through the ESM entry deduplicates. + luxon: path.resolve( + __dirname, + 'node_modules/luxon/build/es6/luxon.mjs' + ), }, extensions: ['.ts', '.tsx', '.js', '.jsx', '.css', '.less', '.svg'], dedupe: [ @@ -169,16 +206,42 @@ export default defineConfig(({ mode }) => { }, }, + preview: { + port: 3000, + proxy: { + '/api/': { + target: devServerTarget, + changeOrigin: true, + ws: true, + }, + }, + }, + build: { outDir: 'dist', assetsDir: 'assets', copyPublicDir: true, sourcemap: false, + // Modern browsers only. Antd 5 / React 18 / Vite 7 already need at least + // these versions; declaring the target lets esbuild emit native async/await, + // optional chaining, nullish coalescing, and top-level await — no + // polyfills, no transpilation overhead. Matches Linear's "no ES5" decision + // (see Linear's bundler-arc blog post). Bundle is typically 5-10% smaller + // and the same browsers we already require keep working. + target: ['chrome93', 'edge93', 'firefox91', 'safari16'], minify: mode === 'production' ? 'esbuild' : false, cssMinify: 'esbuild', cssCodeSplit: true, reportCompressedSize: false, chunkSizeWarningLimit: 1500, + // Vite auto-emits for the entry chunk's + // sync-imported sibling chunks. Keep that behaviour, but drop the polyfill + // — OpenMetadata's React 18 / Antd 5 / Vite 7 toolchain already targets + // modern browsers that support modulepreload natively (Chrome 66+, Edge + // 79+, Safari 17+, Firefox 115+). The polyfill is a small JS shim plus + // one extra script request; on a fast first-paint path even small wins + // count, and we're not the right project to be carrying it. + modulePreload: { polyfill: false }, rollupOptions: { output: { assetFileNames: (assetInfo) => { @@ -193,18 +256,105 @@ export default defineConfig(({ mode }) => { return `assets/[name]-[hash][extname]`; }, manualChunks: (id) => { - if (id.includes('node_modules')) { - if (id.includes('antd')) { - return 'vendor-antd'; - } - if (id.includes('@openmetadata/ui-core-components')) { - return 'vendor-untitled'; - } - if (id.includes('@untitledui/icons')) { - return 'vendor-untitled-icons'; - } + if (!id.includes('node_modules')) { + return; + } + // Antd remains its own vendor chunk — almost every route touches some + // part of it, so the cache-sharing argument holds. Tree-shaking inside + // a single chunk keeps the unused subtrees out anyway. + if (id.includes('antd')) { + return 'vendor-antd'; + } + if (id.includes('@openmetadata/ui-core-components')) { + return 'vendor-untitled'; + } + if (id.includes('@untitledui/icons')) { + return 'vendor-untitled-icons'; + } + // Heavy specialists — each used by a small number of routes. Naming + // them explicitly stops Rollup from co-locating them in a giant shared + // chunk (the prior bundle showed an 8.7 MB chunk containing all of + // these mixed together). Each becomes its own ~100-300 KB chunk that + // routes lazy-load via React.lazy boundaries. + if (id.includes('node_modules/elkjs')) { + return 'vendor-elkjs'; // graph layout, used only by lineage views + } + if (id.includes('node_modules/@reactflow')) { + return 'vendor-reactflow'; // lineage canvas + } + if ( + id.includes('node_modules/prosemirror') || + id.includes('node_modules/@tiptap') + ) { + return 'vendor-prosemirror'; // rich text editor (description editing) + } + if ( + id.includes('node_modules/codemirror') || + id.includes('node_modules/@codemirror') + ) { + return 'vendor-codemirror'; // SQL / query editor + } + if (id.includes('node_modules/recharts')) { + return 'vendor-recharts'; // data insights charts + } + if (id.includes('node_modules/react-latex-next')) { + return 'vendor-latex'; // LaTeX rendering in markdown + } + if (id.includes('node_modules/@melloware/react-logviewer')) { + return 'vendor-logviewer'; // ingestion log viewer + } + if (id.includes('node_modules/showdown')) { + return 'vendor-showdown'; // markdown -> HTML in legacy paths + } + if ( + id.includes('node_modules/quill') || + id.includes('node_modules/@windmillcode/quill-emoji') + ) { + return 'vendor-quill'; // (alternative editor surface) + } + if (id.includes('node_modules/dompurify')) { + return 'vendor-dompurify'; // HTML sanitizer + } + if (id.includes('node_modules/react-data-grid')) { + return 'vendor-datagrid'; // wide-table view + } + if (id.includes('node_modules/luxon')) { + return 'vendor-luxon'; // date library + } + if (id.includes('node_modules/js-yaml')) { + return 'vendor-yaml'; + } + // Linear-style per-package chunking, but with a twist: scoped packages + // get grouped by SCOPE (e.g. every @analytics/foo lands in + // vendor-analytics, every @react-aria/foo lands in vendor-react-aria). + // That's a coarser split than strict per-package but still wins on the + // cache invalidation story — bumping ONE @analytics package invalidates + // ONE chunk, not the whole vendor graph. The reason for grouping by + // scope: many scopes ship dozens of micro-packages (@analytics has 8+, + // @react-aria has 30+), and giving each a 2-3 KB chunk means a + // long tail of HTTP requests that hurts more than the granular cache + // wins. Unscoped packages still get their own chunk. + // + // For specialist scopes that are already explicitly named above + // (@reactflow, @tiptap, @codemirror, @melloware), the explicit rule + // wins and this generic regex never reaches them. + const scopedMatch = id.match(/node_modules[\\/](@[^\\/]+)[\\/]/); + if (scopedMatch) { + const scope = scopedMatch[1].replace('@', ''); + return `vendor-${scope}`; + } + const unscopedMatch = id.match(/node_modules[\\/]([^\\/]+)/); + if (unscopedMatch) { + return `vendor-${unscopedMatch[1]}`; } }, + // Merge any chunk smaller than this back into its primary importer. Keeps + // the per-package split sane for big packages while preventing the long + // tail of ~1 KB utility packages from each becoming their own HTTP + // request. 10 KB is a balance — small enough that lodash / dayjs / + // classnames stay separable, large enough that 200 tiny packages don't + // each get a network roundtrip. + experimentalMinChunkSize: 10 * 1024, }, }, }, diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock index 63e4283f46c0..f3a15e48e132 100644 --- a/openmetadata-ui/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui/src/main/resources/ui/yarn.lock @@ -1780,10 +1780,10 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f" integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg== -"@fontsource/inter@^5.1.1": +"@fontsource-variable/inter@^5.2.8": version "5.2.8" - resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.2.8.tgz#10c95d877d972c7de5bd4592309d42fb6a5e1a5b" - integrity sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg== + resolved "https://registry.yarnpkg.com/@fontsource-variable/inter/-/inter-5.2.8.tgz#29b11476f5149f6a443b4df6516e26002d87941a" + integrity sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ== "@fontsource/poppins@^5.0.0": version "5.2.7" @@ -3541,6 +3541,18 @@ "@tailwindcss/oxide" "4.2.4" tailwindcss "4.2.4" +"@tanstack/query-core@5.100.11": + version "5.100.11" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.11.tgz#fdf273bec49277600311a4c552e2c2d95f4df73b" + integrity sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw== + +"@tanstack/react-query@^5.62.0": + version "5.100.11" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.100.11.tgz#096005bf2868be2f5798c9a48e8f3f7f08c77f20" + integrity sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg== + dependencies: + "@tanstack/query-core" "5.100.11" + "@testing-library/dom@^9.0.0": version "9.3.4" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" @@ -4690,6 +4702,11 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + ansi-styles@^3.2.0: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -4709,6 +4726,11 @@ ansi-styles@^5.0.0, ansi-styles@^5.2.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== +ansi-styles@^6.2.1: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + ansi-to-html@^0.7.2: version "0.7.2" resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.7.2.tgz#a92c149e4184b571eb29a0135ca001a8e2d710cb" @@ -5330,6 +5352,13 @@ builtin-status-codes@^3.0.0: resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" integrity sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ== +bundle-name@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-4.1.0.tgz#f3b96b34160d6431a19d7688135af7cfb8797889" + integrity sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q== + dependencies: + run-applescript "^7.0.0" + call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" @@ -5511,6 +5540,15 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" +cliui@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291" + integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w== + dependencies: + string-width "^7.2.0" + strip-ansi "^7.1.0" + wrap-ansi "^9.0.0" + clone@2.x, clone@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" @@ -6227,6 +6265,19 @@ deepmerge@^4.2.2, deepmerge@~4.3.0: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== +default-browser-id@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/default-browser-id/-/default-browser-id-5.0.1.tgz#f7a7ccb8f5104bf8e0f71ba3b1ccfa5eafdb21e8" + integrity sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q== + +default-browser@^5.4.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/default-browser/-/default-browser-5.5.0.tgz#2792e886f2422894545947cc80e1a444496c5976" + integrity sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw== + dependencies: + bundle-name "^4.1.0" + default-browser-id "^5.0.0" + define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -6236,6 +6287,11 @@ define-data-property@^1.0.1, define-data-property@^1.1.4: es-errors "^1.3.0" gopd "^1.0.1" +define-lazy-prop@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz#dbb19adfb746d7fc6d734a06b72f4a00d021255f" + integrity sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== + define-properties@^1.1.3, define-properties@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" @@ -6464,6 +6520,11 @@ emittery@^0.13.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== +emoji-regex@^10.3.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d" + integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -7307,6 +7368,11 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-east-asian-width@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9" + integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA== + get-intrinsic@^1.1.3, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" @@ -7871,6 +7937,11 @@ is-date-object@^1.0.5, is-date-object@^1.1.0: call-bound "^1.0.2" has-tostringtag "^1.0.2" +is-docker@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200" + integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -7916,6 +7987,18 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" +is-in-ssh@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-in-ssh/-/is-in-ssh-1.0.0.tgz#8eb73c1cabba77748d389588eeea132a63057622" + integrity sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw== + +is-inside-container@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4" + integrity sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA== + dependencies: + is-docker "^3.0.0" + is-map@^2.0.2, is-map@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" @@ -8028,6 +8111,13 @@ is-what@^3.14.1: resolved "https://registry.yarnpkg.com/is-what/-/is-what-3.14.1.tgz#e1222f46ddda85dead0fd1c9df131760e77755c1" integrity sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA== +is-wsl@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-3.1.1.tgz#327897b26832a3eb117da6c27492d04ca132594f" + integrity sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw== + dependencies: + is-inside-container "^1.0.0" + isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" @@ -9553,6 +9643,18 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +open@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/open/-/open-11.0.0.tgz#897e6132f994d3554cbcf72e0df98f176a7e5f62" + integrity sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw== + dependencies: + default-browser "^5.4.0" + define-lazy-prop "^3.0.0" + is-in-ssh "^1.0.0" + is-inside-container "^1.0.0" + powershell-utils "^0.1.0" + wsl-utils "^0.3.0" + openapi-path-templating@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz#57026767530667096d33d7362382a93d75d497f6" @@ -9896,6 +9998,11 @@ postcss@8.5.10, postcss@^8.5.6: picocolors "^1.1.1" source-map-js "^1.2.1" +powershell-utils@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.1.0.tgz#5a42c9a824fb4f2f251ccb41aaae73314f5d6ac2" + integrity sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -11281,6 +11388,16 @@ ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.3: hash-base "^3.1.2" inherits "^2.0.4" +rollup-plugin-visualizer@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.0.1.tgz#291c10ff4a956d9b2483f8b4147b2bf0aacd3a6e" + integrity sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg== + dependencies: + open "^11.0.0" + picomatch "^4.0.2" + source-map "^0.7.4" + yargs "^18.0.0" + rollup@4.59.0, rollup@^4.43.0: version "4.59.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.59.0.tgz#cf74edac17c1486f562d728a4d923a694abdf06f" @@ -11320,6 +11437,11 @@ rope-sequence@^1.3.0: resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425" integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ== +run-applescript@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-7.1.0.tgz#2e9e54c4664ec3106c5b5630e249d3d6595c4911" + integrity sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q== + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -11635,6 +11757,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +source-map@^0.7.4: + version "0.7.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02" + integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== + sourcemap-codec@^1.4.8: version "1.4.8" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" @@ -11714,6 +11841,15 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string-width@^7.0.0, string-width@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== + dependencies: + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" + string.prototype.includes@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz#eceef21283640761a81dbe16d6c7171a4edf7d92" @@ -11810,6 +11946,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-ansi@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -12795,6 +12938,15 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98" + integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== + dependencies: + ansi-styles "^6.2.1" + string-width "^7.0.0" + strip-ansi "^7.1.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -12813,6 +12965,14 @@ ws@8.20.1, ws@^8.11.0, ws@~8.18.3: resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== +wsl-utils@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/wsl-utils/-/wsl-utils-0.3.1.tgz#9479836ddf03be267aad3abfc3cb1f6e0c9f1ed1" + integrity sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg== + dependencies: + is-wsl "^3.1.0" + powershell-utils "^0.1.0" + xhr2@0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.1.3.tgz#cbfc4759a69b4a888e78cf4f20b051038757bd11" @@ -12906,6 +13066,11 @@ yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== +yargs-parser@^22.0.0: + version "22.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" + integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== + yargs@^13.3.0: version "13.3.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" @@ -12935,6 +13100,18 @@ yargs@^17.0.0, yargs@^17.3.1: y18n "^5.0.5" yargs-parser "^21.1.1" +yargs@^18.0.0: + version "18.0.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1" + integrity sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg== + dependencies: + cliui "^9.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + string-width "^7.2.0" + y18n "^5.0.5" + yargs-parser "^22.0.0" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" diff --git a/pom.xml b/pom.xml index b8947814d68a..32d57631096e 100644 --- a/pom.xml +++ b/pom.xml @@ -328,6 +328,11 @@ dropwizard-client ${dropwizard.version} + + io.dropwizard + dropwizard-http2 + ${dropwizard.version} + io.dropwizard dropwizard-testing