mikebridge commented on code in PR #41551: URL: https://github.com/apache/superset/pull/41551#discussion_r3713027586
########## superset-frontend/src/features/versionHistory/SaveGroupItem.tsx: ########## @@ -0,0 +1,386 @@ +/** + * 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 { KeyboardEvent, useState } from 'react'; +import { t, tn } from '@apache-superset/core/translation'; +import { styled, useTheme } from '@apache-superset/core/theme'; +import { Button, Dropdown, Icons, Tag } from '@superset-ui/core/components'; +import type { SaveGroup, VersionedEntityType } from './types'; +import { + formatAuthor, + formatVersionDateTimeShort, + groupHeadline, +} from './display'; +import ActionRow from './ActionRow'; + +/** + * The first chart save serializes the full form_data and can fan out + * into dozens of records; cap the initially visible rows per group. + */ +// TODO(version-history): backend workaround — remove when upstream stops +// exploding the full form_data into per-field records on the first save. +const VISIBLE_RECORD_LIMIT = 10; + +// The highlighted container gains inner padding but extends outward by +// the same amount (negative margin) so its text stays column-aligned +// with non-highlighted neighbors. +const Container = styled.div<{ isPreviewed: boolean }>` + ${({ theme, isPreviewed }) => { + const inset = isPreviewed ? theme.sizeUnit * 3 : 0; + return ` + border-bottom: 1px solid ${theme.colorBorderSecondary}; + background-color: ${isPreviewed ? theme.colorPrimaryBg : 'transparent'}; + border-radius: ${isPreviewed ? theme.borderRadius : 0}px; + padding: ${theme.sizeUnit * 2}px ${inset}px ${theme.sizeUnit * 4}px; + margin: 0 ${-inset}px; + `; + }} +`; + +const Header = styled.div<{ hasRecords: boolean }>` + ${({ theme, hasRecords }) => ` + display: flex; + align-items: flex-start; + gap: ${theme.sizeUnit * 2}px; + padding: ${theme.sizeUnit * 3}px 0; + cursor: ${hasRecords ? 'pointer' : 'default'}; + `} +`; + +const HeaderText = styled.div` + ${({ theme }) => ` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: ${theme.sizeUnit * 2}px; + `} +`; + +const HeadlineRow = styled.div` + ${({ theme }) => ` + display: flex; + align-items: center; + gap: ${theme.sizeUnit * 2}px; + min-width: 0; + `} +`; + +const Headline = styled.div` + ${({ theme }) => ` + font-size: ${theme.fontSize}px; + line-height: ${theme.lineHeight}; + color: ${theme.colorText}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `} +`; + +const Meta = styled.div` + ${({ theme }) => ` + color: ${theme.colorTextTertiary}; + font-size: ${theme.fontSizeSM}px; + line-height: ${theme.lineHeightSM}; + `} +`; + +// Icons and trailing controls center within the first text line (one +// line-height tall) so they track the headline, not the middle of a +// two-line header block. +const IconWrapper = styled.span` + ${({ theme }) => ` + color: ${theme.colorTextSecondary}; + display: flex; + align-items: center; + height: ${theme.fontSize * theme.lineHeight}px; + `} +`; + +const ChevronWrapper = styled.span` + ${({ theme }) => ` + color: ${theme.colorTextTertiary}; + display: flex; + align-items: center; + height: ${theme.fontSize * theme.lineHeight}px; + `} +`; + +const KebabSlot = styled.span` + ${({ theme }) => ` + display: flex; + align-items: center; + height: ${theme.fontSize * theme.lineHeight}px; + `} +`; + +// Icon-only trigger: neutral icon color instead of the link-button blue. +const KebabButton = styled(Button)` + ${({ theme }) => ` + && { + color: ${theme.colorTextTertiary}; + } + &&:hover, + &&:focus { + color: ${theme.colorText}; + } + `} +`; + +const ExpanderRow = styled.div` + ${({ theme }) => ` + padding-left: ${theme.sizeUnit * 8}px; + `} +`; + +export interface SaveGroupItemProps { + entityType: VersionedEntityType; + group: SaveGroup; + /** The newest self save: it IS the live state, not a historical one. */ + isCurrent: boolean; + canRestore: boolean; + isPreviewed: boolean; + onPreview: (group: SaveGroup) => void; + /** Leave an active historical preview (back to the live version). */ + onExitPreview?: () => void; + onRestore: (group: SaveGroup) => void; + onOpenAsNew: (group: SaveGroup) => void; +} + +function GroupKebab({ + entityType, + group, + isCurrent, + canRestore, + onRestore, + onOpenAsNew, +}: Pick< + SaveGroupItemProps, + | 'entityType' + | 'group' + | 'isCurrent' + | 'canRestore' + | 'onRestore' + | 'onOpenAsNew' +>) { + const theme = useTheme(); + if (group.versionUuid == null) { + // Both actions name a specific version; a group the server returned + // without one has nothing for them to act on, and the container + // handlers would silently no-op. No kebab beats a dead menu. + return null; + } + const itemStyle = { + height: theme.controlHeightLG, + paddingLeft: theme.sizeUnit * 6, + paddingRight: theme.sizeUnit * 6, + display: 'flex', + alignItems: 'center', + }; + const menuItems = [ + // Restoring the live version is a no-op; offer it only on history. + ...(isCurrent || !canRestore + ? [] + : [ Review Comment: Confirmed and fixed in `3122eebf34`. The activity hook now models the authoritative Current probe explicitly as `loading | known | unavailable | empty`; Restore is offered only when the state is `known`, so an initial or refresh failure fails closed. The probe starts independently of the visible timeline request, meaning a timeline failure cannot suppress the safety check, and stale probe completions are generation-guarded. Preview remains intentionally available because it is read-only; only the destructive Restore affordance depends on authoritative Current identity. Regression coverage includes initial probe failure, failed refresh, pending reset, independent timeline failure, and the panel loading/error affordances (50 focused tests green). ########## superset-frontend/src/features/versionHistory/api.ts: ########## @@ -0,0 +1,396 @@ +/** + * 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 { JsonObject, SupersetClient } from '@superset-ui/core'; +import rison from 'rison'; +import { t } from '@apache-superset/core/translation'; +import { DASHBOARD_GET_COLUMNS } from 'src/hooks/apiResources/dashboards'; +import { CHART_TYPE, MARKDOWN_TYPE } from 'src/dashboard/util/componentTypes'; +import type { ExploreResponsePayload } from 'src/explore/types'; +import type { + HydrateChartData, + HydrateDashboardData, +} from 'src/dashboard/actions/hydrate'; +import type { Dashboard } from 'src/types/Dashboard'; +import type { + ActivityEntityKind, + ActivityInclude, + ActivityResponse, + ChartVersionSnapshot, + DashboardVersionSnapshot, + VersionedEntityType, + VersionSnapshot, +} from './types'; + +const API_RESOURCE: Record<VersionedEntityType, string> = { + chart: 'chart', + dashboard: 'dashboard', +}; + +export interface FetchActivityOptions { + include?: ActivityInclude; + page?: number; + pageSize?: number; + /** + * Case-insensitive free-text search over the full history (not just the + * loaded pages) — the server filters before paginating, so `count` + * reflects matches. Debounced upstream. + */ + q?: string; +} + +export async function fetchActivity( + entityType: VersionedEntityType, + uuid: string, + { include = 'all', page = 0, pageSize = 25, q }: FetchActivityOptions = {}, +): Promise<ActivityResponse> { + const params = new URLSearchParams({ + include, + page: String(page), + page_size: String(pageSize), + }); + const trimmedQ = q?.trim(); + if (trimmedQ) { + params.set('q', trimmedQ); + } + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/${API_RESOURCE[entityType]}/${encodeURIComponent(uuid)}/activity/?${params.toString()}`, + }); + return json as ActivityResponse; +} + +export async function fetchVersionSnapshot( + entityType: 'chart', + uuid: string, + versionUuid: string, +): Promise<ChartVersionSnapshot>; +export async function fetchVersionSnapshot( + entityType: 'dashboard', + uuid: string, + versionUuid: string, +): Promise<DashboardVersionSnapshot>; +export async function fetchVersionSnapshot( + entityType: VersionedEntityType, + uuid: string, + versionUuid: string, +): Promise<VersionSnapshot>; +export async function fetchVersionSnapshot( + entityType: VersionedEntityType, + uuid: string, + versionUuid: string, +): Promise<VersionSnapshot> { + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/${API_RESOURCE[entityType]}/${encodeURIComponent(uuid)}/versions/${encodeURIComponent(versionUuid)}/`, + }); + return (json as { result: VersionSnapshot }).result; +} + +export async function restoreVersion( + entityType: VersionedEntityType, + uuid: string, + versionUuid: string, +): Promise<{ message: string }> { + const { json } = await SupersetClient.post({ + endpoint: `/api/v1/${API_RESOURCE[entityType]}/${encodeURIComponent(uuid)}/versions/${encodeURIComponent(versionUuid)}/restore`, + }); + return json as { message: string }; +} + +/** Creates a new chart from a version snapshot; returns the new chart id. */ +export async function createChartFromSnapshot( + snapshot: ChartVersionSnapshot, + name: string, +): Promise<number> { + // The chart POST requires all three; the version table allows null for each + // (a delete version carries nulls throughout). Fail here with something the + // caller can turn into a toast rather than sending a payload the API will + // reject with a validation error the user cannot act on. + if ( + snapshot.viz_type == null || + snapshot.datasource_id == null || + snapshot.datasource_type == null + ) { + throw new Error( + 'This version does not record a visualization type and dataset, so a new chart cannot be built from it', + ); + } + const { json } = await SupersetClient.post({ + endpoint: '/api/v1/chart/', + jsonPayload: { + slice_name: name, + viz_type: snapshot.viz_type, + datasource_id: snapshot.datasource_id, + datasource_type: snapshot.datasource_type, + ...(snapshot.params != null && { params: snapshot.params }), + ...(snapshot.query_context != null && { + query_context: snapshot.query_context, + }), + ...(snapshot.description != null && { + description: snapshot.description, + }), + ...(snapshot.cache_timeout != null && { + cache_timeout: snapshot.cache_timeout, + }), + }, + }); + return (json as { id: number }).id; +} + +/** The theme shape `dashboardInfo` holds, keyed by id in the snapshot. */ +export type DashboardTheme = NonNullable<Dashboard['theme']>; + +/** + * Resolves a snapshot's `theme_id` to the theme object hydration expects. + * The version table stores the foreign key, not the theme, so a snapshot + * taken under a different theme than the live dashboard needs one lookup. + */ +export async function fetchDashboardTheme( + themeId: number, +): Promise<DashboardTheme> { + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/theme/${themeId}`, + }); + const { id, result } = json as { + id: number; + result: Omit<DashboardTheme, 'id'>; + }; + return { ...result, id }; Review Comment: Refuting with the pinned framework contract; no code change is needed. Flask-AppBuilder 5.2.2 `ModelRestApi.get_headless()` explicitly sets both `response["id"] = pk` and `response["result"] = show_model_schema.dump(item)` before returning ([source](https://github.com/dpgaspar/Flask-AppBuilder/blob/v5.2.2/flask_appbuilder/api/__init__.py#L1502-L1505)). `ThemeRestApi` inherits that implementation unchanged, and its `show_columns` also includes `id`, so `json.id` and `json.result.id` both exist and identify the same theme. `{ ...result, id }` therefore reassigns the identical primary key; it cannot overwrite the ID with `undefined` under this API contract. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
