This is an automated email from the ASF dual-hosted git repository. pawarprasad123 pushed a commit to branch ATLAS-5246-v1 in repository https://gitbox.apache.org/repos/asf/atlas.git
commit 1a282a3b156fafd99a3b71c8cca1fc57ce015f1b Author: Prasad Pawar <[email protected]> AuthorDate: Fri May 8 12:57:40 2026 +0530 ATLAS-5251: ATLAS UI : Dashboard - Latest Entities List (#619) --- dashboard/src/api/apiMethods/searchApiMethod.ts | 62 ++++- dashboard/src/utils/dashboardSearchUtils.ts | 189 ++++++++++++++ .../views/DashboardOverview/LatestEntitiesList.tsx | 273 +++++++++++++++++++++ .../DashboardOverview/LatestEntitiesSkeleton.tsx | 53 ++++ .../DashboardOverview/latestEntitiesList.utils.ts | 64 +++++ dashboard/src/views/SearchResult/SearchResult.tsx | 24 +- 6 files changed, 658 insertions(+), 7 deletions(-) diff --git a/dashboard/src/api/apiMethods/searchApiMethod.ts b/dashboard/src/api/apiMethods/searchApiMethod.ts index 11a061e4f..8cc58f041 100644 --- a/dashboard/src/api/apiMethods/searchApiMethod.ts +++ b/dashboard/src/api/apiMethods/searchApiMethod.ts @@ -56,10 +56,62 @@ const getRelationShipV2 = (params: { params: Record<string, unknown> }) => { return fetchApi(url, { method: "GET" }); }; +/** Atlas `SearchParameters.sortBy` / `sortOrder` (see `SortOrder` enum: DESCENDING). */ +const LATEST_ENTITIES_TIMESTAMP_SORT = "__timestamp" as const; + +type LatestEntitiesSearchOptions = { + limit: number; + includeSubClassifications: boolean; +}; + +/** + * Request `__timestamp` so sort + “Created … ago” work. Do not set + * `excludeHeaderAttributes`: for `_ALL_ENTITY_TYPES`, Atlas validates each + * `attributes` entry against `__ENTITY_ROOT` and rejects `name` / `qualifiedName` + * / `guid` (see `excludeHeaderAttributesAllEntityType` in Atlas tests). Normal + * headers then include name, guid, typeName like the main basic search. + */ +const buildLatestEntitiesBasicBody = (opts: LatestEntitiesSearchOptions) => { + return { + typeName: "_ALL_ENTITY_TYPES", + excludeDeletedEntities: true, + includeClassificationAttributes: true, + includeSubTypes: true, + includeSubClassifications: opts.includeSubClassifications, + limit: opts.limit, + offset: 0, + tagFilters: null, + entityFilters: null, + classification: null, + termName: null, + relationshipFilters: null, + attributes: ["__timestamp"], + sortBy: LATEST_ENTITIES_TIMESTAMP_SORT, + sortOrder: "DESCENDING", + }; +}; + +/** + * Dashboard card only: newest entities by `__timestamp`, no entity filters, + * sub-classifications off, full entity headers for name/guid/type. + */ +const getLatestEntities = () => { + return getBasicSearchResult( + { + data: buildLatestEntitiesBasicBody({ + limit: 7, + includeSubClassifications: false, + }), + }, + "basic" + ); +}; + export { - getBasicSearchResult, - getRelationShipResult, - getGlobalSearchResult, - getRelationShip, - getRelationShipV2 + getBasicSearchResult, + getRelationShipResult, + getGlobalSearchResult, + getRelationShip, + getRelationShipV2, + getLatestEntities }; diff --git a/dashboard/src/utils/dashboardSearchUtils.ts b/dashboard/src/utils/dashboardSearchUtils.ts new file mode 100644 index 000000000..15ebdf699 --- /dev/null +++ b/dashboard/src/utils/dashboardSearchUtils.ts @@ -0,0 +1,189 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 type { NavigateFunction } from "react-router-dom"; +import { attributeFilter } from "@utils/CommonViewFunction"; + +export type DashboardSearchType = + | "all_entities" + | "all_classifications" + | "entity_status" + | "entity_type"; + +export interface DashboardSearchParams { + type?: string; + tag?: string; + includeDE?: boolean; + entityFilters?: { + condition: string; + criterion: Array<{ attributeName: string; operator: string; attributeValue: string }>; + }; +} + +const buildSearchParams = ( + type: DashboardSearchType, + extra?: Partial<DashboardSearchParams> +): URLSearchParams => { + const params = new URLSearchParams(); + params.set("searchType", "basic"); + + if (type === "all_classifications") { + params.set("tag", extra?.tag ?? "_ALL_CLASSIFICATION_TYPES"); + params.set("type", ""); + } else { + params.set("type", extra?.type ?? "_ALL_ENTITY_TYPES"); + } + if (extra?.includeDE) { + params.set("includeDE", "true"); + } + if (extra?.entityFilters) { + const urlStr = attributeFilter.generateUrl({ value: extra.entityFilters }); + if (urlStr) params.set("entityFilters", urlStr); + } + return params; +}; + +export const navigateToTaggedSearch = (navigate: NavigateFunction): void => { + navigateToSearch(navigate, "all_classifications"); +}; + +export const navigateToClassificationSearch = ( + navigate: NavigateFunction, + tagName: string +): void => { + navigateToSearch(navigate, "all_classifications", { tag: tagName }); +}; + +/** "View All" from Latest Entities card: basic all-types search, newest first. */ +export const navigateToLatestEntitiesSearch = (navigate: NavigateFunction): void => { + const params = buildSearchParams("all_entities"); + params.set("pageLimit", "25"); + params.set("pageOffset", "0"); + params.set("sortBy", "__timestamp"); + params.set("sortOrder", "DESCENDING"); + /* Same as dashboard card: request system create time in the basic-search body. */ + params.set("attributes", "__timestamp"); + navigate({ pathname: "/search/searchResult", search: params.toString() }); +}; + +export const navigateToEntityTypeSearch = ( + navigate: NavigateFunction, + typeName: string, + includeDeleted: boolean +): void => { + const extra: Partial<DashboardSearchParams> = { + type: typeName, + includeDE: includeDeleted + }; + if (includeDeleted) { + extra.entityFilters = { + condition: "AND", + criterion: [{ attributeName: "__state", operator: "eq", attributeValue: "DELETED" }] + }; + } + navigateToSearch(navigate, "entity_type", extra); +}; + +/** Search all entity typedefs that belong to one service-type bucket (sidebar grouping). */ +export const navigateToServiceTypeEntitySearch = ( + navigate: NavigateFunction, + typeNames: string[], + includeDeleted: boolean +): void => { + const cleaned = [...new Set(typeNames.filter(Boolean))].sort((a, b) => a.localeCompare(b)); + if (cleaned.length === 0) { + navigateToSearch(navigate, "all_entities"); + return; + } + if (cleaned.length === 1) { + navigateToEntityTypeSearch(navigate, cleaned[0], includeDeleted); + return; + } + const criterion = cleaned.map((typeName) => ({ + attributeName: "__typeName", + operator: "eq", + attributeValue: typeName, + })); + navigateToSearch(navigate, "all_entities", { + type: "_ALL_ENTITY_TYPES", + includeDE: includeDeleted, + entityFilters: { condition: "OR", criterion }, + }); +}; + +export const navigateToSearch = ( + navigate: NavigateFunction, + type: DashboardSearchType, + extra?: Partial<DashboardSearchParams> +): void => { + const search = buildSearchParams(type, extra).toString(); + navigate({ pathname: "/search/searchResult", search }); +}; + +/** Basic search using free-text `query` (e.g. entity typedef name). */ +export const navigateToBasicTextQuery = ( + navigate: NavigateFunction, + query: string +): void => { + const params = new URLSearchParams(); + params.set("searchType", "basic"); + params.set("query", query.trim()); + params.set("pageLimit", "25"); + params.set("pageOffset", "0"); + navigate({ pathname: "/search/searchResult", search: params.toString() }); +}; + +export const navigateToClassificationDetailPage = ( + navigate: NavigateFunction, + classificationName: string +): void => { + const sp = new URLSearchParams(); + sp.set("tag", classificationName); + navigate({ + pathname: `/tag/tagAttribute/${encodeURIComponent(classificationName)}`, + search: sp.toString() + }); +}; + +export const navigateToGlossaryTermDetailPage = ( + navigate: NavigateFunction, + args: { + termGuid: string; + termId: string; + glossaryGuid: string; + parentName: string; + } +): void => { + const sp = new URLSearchParams(); + sp.set("gid", args.glossaryGuid); + sp.set("term", `${args.termId}@${args.parentName}`); + sp.set("gtype", "term"); + sp.set("viewType", "term"); + sp.set("guid", args.termGuid); + sp.set("searchType", "basic"); + navigate({ + pathname: `/glossary/${args.termGuid}`, + search: sp.toString() + }); +}; + +export const navigateToBusinessMetadataDetailPage = ( + navigate: NavigateFunction, + bmGuid: string +): void => { + navigate({ pathname: `/administrator/businessMetadata/${bmGuid}` }); +}; diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx new file mode 100644 index 000000000..2d80a5a1f --- /dev/null +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesList.tsx @@ -0,0 +1,273 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 { memo, useCallback } from "react"; +import { Paper, Stack, Typography, Link, List, ListItem, Box } from "@mui/material"; +import { Link as RouterLink } from "react-router-dom"; +import moment from "moment"; +import { useNavigate } from "react-router-dom"; +import { navigateToLatestEntitiesSearch } from "@utils/dashboardSearchUtils"; +import type { LatestEntityRowModel } from "./latestEntitiesList.utils"; +import { + resolveLatestEntityDisplayName, + resolveLatestEntityGuid, + resolveLatestEntityTypeName +} from "./latestEntitiesList.utils"; + +interface EntityItem extends LatestEntityRowModel { + createTime?: number | Date | string; + attributes?: LatestEntityRowModel["attributes"] & { + __timestamp?: number | string | Record<string, unknown>; + createTime?: number | string; + }; +} + +interface LatestEntitiesListProps { + entities: EntityItem[]; + isLoading?: boolean; + error?: string | null; +} + +const INVALID_TS_LABEL = "Created today"; + +const unwrapLongLike = (raw: unknown): unknown => { + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return raw; + const o = raw as Record<string, unknown>; + if (typeof o.$numberLong === "string" || typeof o.$numberLong === "number") { + return o.$numberLong; + } + if (typeof o.longValue === "string" || typeof o.longValue === "number") { + return o.longValue; + } + return raw; +}; + +/** + * Milliseconds since epoch, or null only when missing / unusable. + * Rejects 0 (epoch) to avoid "56 years ago". Accepts sec or ms numbers, ISO strings. + */ +const normalizeEntityTimestampMs = (raw: unknown): number | null => { + const v = unwrapLongLike(raw); + if (v == null) return null; + if (v instanceof Date) { + const t = v.getTime(); + if (!Number.isFinite(t) || t <= 0) return null; + return moment(t).isValid() ? t : null; + } + if (typeof v === "string") { + const trimmed = v.trim(); + if (trimmed === "") return null; + const n = Number(trimmed); + if (Number.isFinite(n) && n > 0) { + const ms = n < 1e12 ? n * 1000 : n; + if (moment(ms).isValid() && ms > 0) return ms; + } + const parsed = moment(trimmed); + if (parsed.isValid()) { + const ms = parsed.valueOf(); + if (ms > 0) return ms; + } + return null; + } + if (typeof v === "number") { + if (!Number.isFinite(v) || v <= 0) return null; + const ms = v < 1e12 ? v * 1000 : v; + return moment(ms).isValid() && ms > 0 ? ms : null; + } + return null; +}; + +/** Use only `__timestamp` for relative "Created … ago" (not `createTime`, often 0 in API). */ +const getEntityTimestampRawForDisplay = (entity: EntityItem): unknown => { + const a = entity.attributes; + const top = entity as EntityItem & { __timestamp?: unknown }; + return a?.__timestamp ?? top.__timestamp; +}; + +/** Valid timestamp only: seconds / minutes / hours for last 24h, then moment relative. */ +const formatCreatedRelativeFromMs = (ms: number): string => { + const now = Date.now(); + const deltaMs = now - ms; + if (!Number.isFinite(deltaMs)) return INVALID_TS_LABEL; + if (deltaMs < 0) { + return `Created ${moment(ms).fromNow()}`; + } + const totalSec = Math.floor(deltaMs / 1000); + if (totalSec < 1) { + return "Created just now"; + } + if (totalSec < 60) { + return totalSec === 1 + ? "Created 1 second ago" + : `Created ${totalSec} seconds ago`; + } + const totalMin = Math.floor(totalSec / 60); + if (totalMin < 60) { + return totalMin === 1 + ? "Created 1 minute ago" + : `Created ${totalMin} minutes ago`; + } + const totalHr = Math.floor(totalMin / 60); + if (totalHr < 24) { + return totalHr === 1 + ? "Created 1 hour ago" + : `Created ${totalHr} hours ago`; + } + return `Created ${moment(ms).fromNow()}`; +}; + +const formatRelativeTime = (raw: unknown): string => { + const ms = normalizeEntityTimestampMs(raw); + if (ms == null) return INVALID_TS_LABEL; + return formatCreatedRelativeFromMs(ms); +}; + +const LatestEntitiesList = memo(({ entities, isLoading, error }: LatestEntitiesListProps) => { + const navigate = useNavigate(); + + const handleViewAll = useCallback(() => { + navigateToLatestEntitiesSearch(navigate); + }, [navigate]); + + if (isLoading) return null; + + return ( + <Paper + elevation={1} + sx={{ + padding: 2, + borderRadius: 2, + minHeight: 340, + minWidth: 0, + width: "100%", + flex: 1, + boxSizing: "border-box", + transition: "box-shadow 0.3s ease", + "&:hover": { boxShadow: 4 } + }} + > + <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}> + <Stack direction="row" justifyContent="space-between" alignItems="center"> + <Typography sx={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a" }}> + Latest Entities Created + </Typography> + <Link + component="button" + onClick={handleViewAll} + sx={{ + fontSize: "0.875rem", + cursor: "pointer", + textDecoration: "none", + color: "primary.main" + }} + aria-label="View all entities" + > + View All + </Link> + </Stack> + </Box> + {error ? ( + <Stack alignItems="center" justifyContent="center" height={200} sx={{ pt: 2 }}> + <Typography variant="body2" color="error"> + {error} + </Typography> + </Stack> + ) : !entities || entities.length === 0 ? ( + <Stack alignItems="center" justifyContent="center" height={200} sx={{ pt: 2 }}> + <Typography variant="body2" color="text.secondary"> + No recent entities + </Typography> + </Stack> + ) : ( + <List disablePadding sx={{ pt: 2 }}> + {entities.slice(0, 7).map((entity) => { + const displayName = resolveLatestEntityDisplayName(entity); + const entityGuid = resolveLatestEntityGuid(entity); + const typeName = resolveLatestEntityTypeName(entity); + const timestamp = getEntityTimestampRawForDisplay(entity); + const detailHref = entityGuid + ? `/detailPage/${entityGuid}` + : undefined; + + return ( + <ListItem + key={entityGuid || displayName} + disablePadding + sx={{ + py: 1, + borderBottom: "1px solid", + borderColor: "divider", + "&:last-child": { borderBottom: "none" } + }} + > + <Stack width="100%" direction="row" justifyContent="space-between" alignItems="center"> + <Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap" flex={1} minWidth={0} mr={1}> + {detailHref ? ( + <Link + component={RouterLink} + to={detailHref} + underline="hover" + color="primary" + sx={{ + fontSize: "0.875rem", + overflow: "hidden", + textOverflow: "ellipsis", + cursor: "pointer", + maxWidth: "100%" + }} + > + {displayName} + </Link> + ) : ( + <Typography + component="span" + sx={{ + fontSize: "0.875rem", + fontWeight: 500, + color: "text.primary" + }} + > + {displayName} + </Typography> + )} + <Typography + component="span" + sx={{ + fontSize: "0.875rem", + color: "#6c757d", + flexShrink: 0 + }} + > + ({typeName}) + </Typography> + </Stack> + <Typography sx={{ fontSize: "0.8125rem", color: "#6c757d", flexShrink: 0, ml: 1 }}> + {formatRelativeTime(timestamp)} + </Typography> + </Stack> + </ListItem> + ); + })} + </List> + )} + </Paper> + ); +}); + +LatestEntitiesList.displayName = "LatestEntitiesList"; + +export default LatestEntitiesList; diff --git a/dashboard/src/views/DashboardOverview/LatestEntitiesSkeleton.tsx b/dashboard/src/views/DashboardOverview/LatestEntitiesSkeleton.tsx new file mode 100644 index 000000000..75e4f5960 --- /dev/null +++ b/dashboard/src/views/DashboardOverview/LatestEntitiesSkeleton.tsx @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 { Paper, Stack, Box } from "@mui/material"; +import SkeletonLoader from "@components/SkeletonLoader"; + +const LatestEntitiesSkeleton = () => ( + <Paper + elevation={1} + sx={{ + padding: 2, + borderRadius: 2, + minHeight: 340, + minWidth: 0, + width: "100%", + flex: 1, + boxSizing: "border-box", + transition: "box-shadow 0.3s ease", + "&:hover": { boxShadow: 4 } + }} + > + <Box sx={{ pb: 2, borderBottom: "1px solid", borderColor: "divider" }}> + <Stack direction="row" justifyContent="space-between" alignItems="center"> + <SkeletonLoader animation="wave" variant="text" count={1} width="60%" height={24} /> + <SkeletonLoader animation="wave" variant="text" count={1} width={60} height={20} /> + </Stack> + </Box> + <Stack spacing={1} sx={{ pt: 2 }}> + {[1, 2, 3, 4, 5, 6, 7].map((i) => ( + <Stack key={i} direction="row" justifyContent="space-between" alignItems="center" sx={{ py: 0.5 }}> + <SkeletonLoader animation="wave" variant="text" count={1} width={`${70 + (i % 3) * 10}%`} height={20} /> + <SkeletonLoader animation="wave" variant="text" count={1} width={80} height={18} /> + </Stack> + ))} + </Stack> + </Paper> +); + +export default LatestEntitiesSkeleton; diff --git a/dashboard/src/views/DashboardOverview/latestEntitiesList.utils.ts b/dashboard/src/views/DashboardOverview/latestEntitiesList.utils.ts new file mode 100644 index 000000000..118be6b15 --- /dev/null +++ b/dashboard/src/views/DashboardOverview/latestEntitiesList.utils.ts @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +/** Shape of basic-search entity headers used by Latest Entities card. */ +export interface LatestEntityRowModel { + guid?: string; + name?: string; + typeName?: string; + displayText?: string; + attributes?: { + name?: string; + qualifiedName?: string; + __guid?: string; + }; +} + +/** + * Match Search name column: top-level name/displayText and attributes.name / + * qualifiedName (Atlas may return any of these). + */ +export const resolveLatestEntityDisplayName = ( + entity: LatestEntityRowModel +): string => { + const n = + entity.name ?? + entity.attributes?.name ?? + entity.attributes?.qualifiedName ?? + entity.displayText ?? + entity.guid; + return typeof n === "string" && n.trim() !== "" ? n.trim() : "Unknown"; +}; + +export const resolveLatestEntityGuid = ( + entity: LatestEntityRowModel +): string | undefined => { + const g = + entity.guid ?? + (typeof entity.attributes?.__guid === "string" + ? entity.attributes.__guid + : undefined); + if (typeof g !== "string" || g.trim() === "") return undefined; + return g.trim(); +}; + +export const resolveLatestEntityTypeName = ( + entity: LatestEntityRowModel +): string => { + const t = entity.typeName; + return typeof t === "string" && t.trim() !== "" ? t.trim() : "Entity"; +}; diff --git a/dashboard/src/views/SearchResult/SearchResult.tsx b/dashboard/src/views/SearchResult/SearchResult.tsx index a026e1175..5d850d93a 100644 --- a/dashboard/src/views/SearchResult/SearchResult.tsx +++ b/dashboard/src/views/SearchResult/SearchResult.tsx @@ -71,6 +71,9 @@ interface Params { classification: any; termName: string | null; relationshipFilters?: string | null; + sortBy?: string; + sortOrder?: string; + excludeHeaderAttributes?: boolean; } let defaultColumnsName: Array<string> = [ @@ -155,6 +158,15 @@ const SearchResult = ({ classificationParams, glossaryTypeParams, hideFilters }: ? !searchParams.get("excludeST") : true, includeClassificationAttributes: true, + ...(!isEmpty(searchParams.get("sortBy")) && { + sortBy: searchParams.get("sortBy") + }), + ...(!isEmpty(searchParams.get("sortOrder")) && { + sortOrder: searchParams.get("sortOrder") + }), + ...(searchParams.get("excludeHeaderAttributes") === "true" && { + excludeHeaderAttributes: true + }), ...(isEmpty(classificationParams || glossaryTypeParams) && { entityFilters: !isEmpty(entityFilterParams) ? searchParamsAPiQuery(entityFilterParams) @@ -1000,12 +1012,20 @@ const SearchResult = ({ classificationParams, glossaryTypeParams, hideFilters }: return hideColumns; }; + const latestEntitiesSortBy = searchParams.get("sortBy"); + const latestEntitiesSortOrder = searchParams.get("sortOrder"); const getDefaultSort = useMemo(() => { if (isDslAggregate) { return [] as any[]; // no default sorting for DSL aggregates } - return [{ id: "name", asc: true }]; - }, [isDslAggregate]); + if ( + latestEntitiesSortBy === "__timestamp" && + latestEntitiesSortOrder === "DESCENDING" + ) { + return [{ id: "__timestamp", desc: true }]; + } + return [{ id: "name", desc: false }]; + }, [isDslAggregate, latestEntitiesSortBy, latestEntitiesSortOrder]); return ( <Stack position="relative" gap={"1rem"}>
