diff --git a/.bitmap b/.bitmap index dcac0451b9bc..8f262e5a6d37 100644 --- a/.bitmap +++ b/.bitmap @@ -1329,6 +1329,12 @@ "mainFile": "index.ts", "rootDir": "scopes/code/ui/code-tab-tree" }, + "ui/code-view": { + "scope": "teambit.code", + "version": "5e2b49420c500d4d88e1b05aa232061abc0fa0a7", + "mainFile": "index.ts", + "rootDir": "components/ui/code-view" + }, "ui/compare/lane-compare": { "scope": "teambit.lanes", "version": "0.0.84", diff --git a/components/ui/code-view/code-view.module.scss b/components/ui/code-view/code-view.module.scss new file mode 100644 index 000000000000..f5fe367a61fd --- /dev/null +++ b/components/ui/code-view/code-view.module.scss @@ -0,0 +1,44 @@ +.codeView { + padding: 24px 40px; + width: 100%; + overflow: hidden; + box-sizing: border-box; + + .codeSnippetWrapper { + width: 100%; + max-width: 100%; + .codeSnippet { + display: block; + overflow: auto; + height: calc(100vh - 200px); + > code { + > code { + // this is to design the line numbers culumn + border-right: 1px solid #323232; + padding-right: 16px !important; + margin-right: 16px; + } + } + } + // TODO - fix this in code snippet component. it breaks when the component renders in different places + > span { + top: 13px !important; + } + } +} + +.img { + width: 20px; + margin-right: 10px; +} + +.fileName { + display: flex; + align-items: baseline; +} + +.emptyCodeView { + margin: auto; + text-align: center; + font-size: 24px; +} diff --git a/components/ui/code-view/code-view.tsx b/components/ui/code-view/code-view.tsx new file mode 100644 index 000000000000..26dd854865f5 --- /dev/null +++ b/components/ui/code-view/code-view.tsx @@ -0,0 +1,76 @@ +import { H1 } from '@teambit/documenter.ui.heading'; +import classNames from 'classnames'; +import React, { HTMLAttributes, useMemo } from 'react'; +import { CodeSnippet } from '@teambit/documenter.ui.code-snippet'; +import { useFileContent } from '@teambit/code.ui.queries.get-file-content'; +import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; +import markDownSyntax from 'react-syntax-highlighter/dist/esm/languages/prism/markdown'; +import { staticStorageUrl } from '@teambit/base-ui.constants.storage'; +import { ComponentID } from '@teambit/component'; +import styles from './code-view.module.scss'; + +export type CodeViewProps = { + componentId: ComponentID; + currentFile?: string; + currentFileContent?: string; + icon?: string; + loading?: boolean; + codeSnippetClassName?: string; +} & HTMLAttributes; + +SyntaxHighlighter.registerLanguage('md', markDownSyntax); + +export function CodeView({ + className, + componentId, + currentFile, + icon, + currentFileContent, + codeSnippetClassName, + loading: loadingFromProps, +}: CodeViewProps) { + const { fileContent: downloadedFileContent, loading: loadingFileContent } = useFileContent( + componentId, + currentFile, + !!currentFileContent + ); + const loading = loadingFromProps || loadingFileContent; + const fileContent = currentFileContent || downloadedFileContent; + const title = useMemo(() => currentFile?.split('/').pop(), [currentFile]); + const lang = useMemo(() => { + const langFromFileEnding = currentFile?.split('.').pop(); + + // for some reason, SyntaxHighlighter doesnt support scss or sass highlighting, only css. I need to check how to fix this properly + if (langFromFileEnding === 'scss' || langFromFileEnding === 'sass') return 'css'; + if (langFromFileEnding === 'mdx') return 'md'; + return langFromFileEnding; + }, [fileContent]); + + if (!fileContent && !loading && currentFile) return ; + + return ( +
+

+ {currentFile && } + {title} +

+ + {fileContent || ''} + +
+ ); +} + +function EmptyCodeView() { + return ( +
+ +
Nothing to show
+
+ ); +} diff --git a/components/ui/code-view/index.ts b/components/ui/code-view/index.ts new file mode 100644 index 000000000000..3dd57f347c74 --- /dev/null +++ b/components/ui/code-view/index.ts @@ -0,0 +1,2 @@ +export { CodeView } from './code-view'; +export type { CodeViewProps } from './code-view'; diff --git a/components/ui/component-compare/component-compare/component-compare.tsx b/components/ui/component-compare/component-compare/component-compare.tsx index ea837623333b..94aa2b7703b8 100644 --- a/components/ui/component-compare/component-compare/component-compare.tsx +++ b/components/ui/component-compare/component-compare/component-compare.tsx @@ -79,7 +79,15 @@ export function ComponentCompare(props: ComponentCompareProps) { component: base, loading: loadingBase, componentDescriptor: baseComponentDescriptor, - } = useComponent(host, baseId.toString(), { customUseComponent }); + } = useComponent(host, baseId.toString(), { + customUseComponent, + logFilters: { + log: { + logLimit: 3, + }, + }, + }); + const { component: compareComponent, loading: loadingCompare, @@ -87,6 +95,11 @@ export function ComponentCompare(props: ComponentCompareProps) { } = useComponent(host, _compareId?.toString() || '', { skip: !_compareId, customUseComponent, + logFilters: { + log: { + logLimit: 3, + }, + }, }); const loading = loadingBase || loadingCompare; @@ -170,7 +183,11 @@ function RenderCompareScreen(props: ComponentCompareProps) { return ( <> {showVersionPicker && ( -
{state?.versionPicker?.element || }
+
+ {state?.versionPicker?.element || ( + + )} +
)}
diff --git a/components/ui/component-compare/version-picker/component-compare-version-picker.tsx b/components/ui/component-compare/version-picker/component-compare-version-picker.tsx index 96f8e60902ba..174940355de0 100644 --- a/components/ui/component-compare/version-picker/component-compare-version-picker.tsx +++ b/components/ui/component-compare/version-picker/component-compare-version-picker.tsx @@ -1,36 +1,57 @@ -import React, { HTMLAttributes, useMemo } from 'react'; -import { DropdownComponentVersion, VersionDropdown } from '@teambit/component.ui.version-dropdown'; +import React, { HTMLAttributes } from 'react'; +import { VersionDropdown } from '@teambit/component.ui.version-dropdown'; import { useUpdatedUrlFromQuery } from '@teambit/component.ui.component-compare.hooks.use-component-compare-url'; import { useComponentCompare } from '@teambit/component.ui.component-compare.context'; +import { UseComponentType, useComponent } from '@teambit/component'; import classNames from 'classnames'; import styles from './component-compare-version-picker.module.scss'; -export type ComponentCompareVersionPickerProps = {} & HTMLAttributes; +export type ComponentCompareVersionPickerProps = { + customUseComponent?: UseComponentType; + host: string; +} & HTMLAttributes; -export function ComponentCompareVersionPicker({ className }: ComponentCompareVersionPickerProps) { +export function ComponentCompareVersionPicker({ + className, + host, + customUseComponent, +}: ComponentCompareVersionPickerProps) { const componentCompare = useComponentCompare(); const compare = componentCompare?.compare?.model; - - const logs = - (compare?.logs || []).filter((log) => { - const version = log.tag || log.hash; - return componentCompare?.compare?.hasLocalChanges || version !== compare?.id.version; - }) || []; - - const [tags, snaps] = useMemo(() => { - return (logs || []).reduce( - ([_tags, _snaps], log) => { - if (!log.tag) { - _snaps.push({ ...log, version: log.hash }); - } else { - _tags.push({ ...log, version: log.tag as string }); - } - return [_tags, _snaps]; + const componentId = compare?.id.toString(); + const componentWithLogsOptions = { + logFilters: { + snapLog: { + logLimit: 10, }, - [new Array(), new Array()] - ); - }, [logs]); + tagLog: { + logLimit: 10, + }, + fetchLogsByTypeSeparately: true, + }, + customUseComponent, + }; + + const useVersions = () => { + const { componentLogs = {}, loading: loadingLogs } = useComponent(host, componentId, componentWithLogsOptions); + return { + loading: loadingLogs, + ...componentLogs, + snaps: (componentLogs.snaps || []) + .map((snap) => ({ ...snap, version: snap.hash })) + .filter((log) => { + const version = log.tag || log.hash; + return componentCompare?.compare?.hasLocalChanges || version !== compare?.id.version; + }), + tags: (componentLogs.tags || []) + .map((tag) => ({ ...tag, version: tag.tag as string })) + .filter((log) => { + const version = log.tag || log.hash; + return componentCompare?.compare?.hasLocalChanges || version !== compare?.id.version; + }), + }; + }; const compareVersion = componentCompare?.compare?.hasLocalChanges ? 'workspace' : compare?.version; @@ -47,15 +68,15 @@ export function ComponentCompareVersionPicker({ className }: ComponentCompareVer dropdownClassName={styles.componentCompareDropdown} placeholderClassName={styles.componentCompareVersionPlaceholder} menuClassName={classNames(styles.componentCompareVersionMenu, styles.showMenuOverNav)} - snaps={snaps} - tags={tags} currentVersion={baseVersion as string} loading={componentCompare?.loading} overrideVersionHref={(_baseVersion) => { return useUpdatedUrlFromQuery({ baseVersion: _baseVersion }); }} - disabled={snaps.concat(tags).length < 2} + disabled={(compare?.logs?.length ?? 0) < 2} + hasMoreVersions={(compare?.logs?.length ?? 0) > 1} showVersionDetails={true} + useComponentVersions={useVersions} />
with
; export function LanePlaceholder({ @@ -18,12 +19,17 @@ export function LanePlaceholder({ disabled, className, showScope = true, + loading, ...rest }: LanePlaceholderProps) { const laneIdStr = selectedLaneId?.isDefault() ? selectedLaneId.name : (showScope && selectedLaneId?.toString()) || selectedLaneId?.name; + if (loading) { + return null; + } + return (
diff --git a/components/ui/inputs/lane-selector/lane-selector-list.tsx b/components/ui/inputs/lane-selector/lane-selector-list.tsx index c6d1e69bef31..87dd227ad493 100644 --- a/components/ui/inputs/lane-selector/lane-selector-list.tsx +++ b/components/ui/inputs/lane-selector/lane-selector-list.tsx @@ -39,6 +39,7 @@ export function LaneSelectorList({ listNavigator, // eslint-disable-next-line @typescript-eslint/no-unused-vars forceCloseOnEnter, + loading, ...rest }: LaneSelectorListProps) { const navigate = useNavigate(); @@ -156,6 +157,8 @@ export function LaneSelectorList({ } }, [selectedLaneId?.toString()]); + if (loading) return null; + return (
{groupByScope && diff --git a/components/ui/inputs/lane-selector/lane-selector.tsx b/components/ui/inputs/lane-selector/lane-selector.tsx index d3e9cca881f4..e12d723298d9 100644 --- a/components/ui/inputs/lane-selector/lane-selector.tsx +++ b/components/ui/inputs/lane-selector/lane-selector.tsx @@ -29,6 +29,7 @@ export type LaneSelectorProps = { sortOptions?: LaneSelectorSortBy[]; scopeIconLookup?: Map; forceCloseOnEnter?: boolean; + loading?: boolean; } & HTMLAttributes; export type GroupedLaneDropdownItem = [scope: string, lanes: LaneModel[]]; @@ -61,6 +62,7 @@ export function LaneSelector(props: LaneSelectorProps) { scopeIcon, scopeIconLookup, forceCloseOnEnter, + loading, ...rest } = props; @@ -235,7 +237,12 @@ export function LaneSelector(props: LaneSelectorProps) { }); }} placeholderContent={ - + } className={classnames(styles.dropdown, !multipleLanes && styles.disabled)} > diff --git a/components/ui/navigation/lane-switcher/lane-switcher.module.scss b/components/ui/navigation/lane-switcher/lane-switcher.module.scss index 08e4c1795efe..14ae9c1fa63e 100644 --- a/components/ui/navigation/lane-switcher/lane-switcher.module.scss +++ b/components/ui/navigation/lane-switcher/lane-switcher.module.scss @@ -35,3 +35,12 @@ flex: 0; width: 36px; } + +.loader { + color: var(--bit-bg-dent, #f6f6f6); + padding: 4px 0px; + + > span { + padding: 8px; + } +} diff --git a/components/ui/navigation/lane-switcher/lane-switcher.tsx b/components/ui/navigation/lane-switcher/lane-switcher.tsx index 14842a91de37..b44afc7addae 100644 --- a/components/ui/navigation/lane-switcher/lane-switcher.tsx +++ b/components/ui/navigation/lane-switcher/lane-switcher.tsx @@ -1,9 +1,10 @@ -import React, { HTMLAttributes, useEffect, useState, useRef } from 'react'; +import React, { HTMLAttributes, useRef } from 'react'; import { useLanes as defaultUseLanes } from '@teambit/lanes.hooks.use-lanes'; import { LaneSelector, LaneSelectorSortBy } from '@teambit/lanes.ui.inputs.lane-selector'; import { LanesModel } from '@teambit/lanes.ui.models.lanes-model'; import { MenuLinkItem } from '@teambit/design.ui.surfaces.menu.link-item'; import { LaneId } from '@teambit/lane-id'; +import { WordSkeleton } from '@teambit/base-ui.loaders.skeleton'; import classnames from 'classnames'; import styles from './lane-switcher.module.scss'; @@ -29,10 +30,8 @@ export function LaneSwitcher({ getHref = LanesModel.getLaneUrl, ...rest }: LaneSwitcherProps) { - const { lanesModel } = useLanes(); - const [viewedLane, setViewedLane] = useState(lanesModel?.viewedLane); const containerRef = useRef(null); - + const { lanesModel, loading } = useLanes(); const mainLane = lanesModel?.getDefaultLane(); const nonMainLanes = lanesModel?.getNonMainLanes() || []; @@ -45,37 +44,37 @@ export function LaneSwitcher({ : [] ); - useEffect(() => { - if (lanesModel?.viewedLane?.id.toString() !== viewedLane?.id.toString()) { - setViewedLane(lanesModel?.viewedLane); - } - }, [lanesModel?.viewedLane?.id.toString()]); - - const selectedLane = viewedLane || mainLane; + const selectedLane = lanesModel?.viewedLane || mainLane; const selectedLaneGalleryHref = selectedLane && getHref(selectedLane.id); return (
- -
-
- - - + {loading && } + { + + }
+ {!loading && ( +
+ + + +
+ )}
); } diff --git a/scopes/code/ui/code-tab-page/code-tab-page.tsx b/scopes/code/ui/code-tab-page/code-tab-page.tsx index 0d698d0b02b7..499e63e3eccb 100644 --- a/scopes/code/ui/code-tab-page/code-tab-page.tsx +++ b/scopes/code/ui/code-tab-page/code-tab-page.tsx @@ -32,9 +32,10 @@ import styles from './code-tab-page.module.scss'; export type CodePageProps = { fileIconSlot?: FileIconSlot; host: string; + codeViewClassName?: string; } & HTMLAttributes; -export function CodePage({ className, fileIconSlot, host }: CodePageProps) { +export function CodePage({ className, fileIconSlot, host, codeViewClassName }: CodePageProps) { const urlParams = useCodeParams(); const component = useContext(ComponentContext); const { mainFile, fileTree = [], dependencies, devFiles } = useCode(component.id); @@ -67,6 +68,7 @@ export function CodePage({ className, fileIconSlot, host }: CodePageProps) { , + element: , }; navigationLink = { href: '~changelog', diff --git a/scopes/component/changelog/changelog.ui.runtime.tsx b/scopes/component/changelog/changelog.ui.runtime.tsx index 548b2e69c91d..2b1b49ab30a2 100644 --- a/scopes/component/changelog/changelog.ui.runtime.tsx +++ b/scopes/component/changelog/changelog.ui.runtime.tsx @@ -1,5 +1,6 @@ import { ComponentAspect, ComponentUI } from '@teambit/component'; import { UIRuntime } from '@teambit/ui'; +import { Harmony } from '@teambit/harmony'; import React from 'react'; import { ChangelogAspect } from './changelog.aspect'; @@ -7,17 +8,20 @@ import { ChangelogSection } from './changelog.section'; import { ChangeLogPage } from './ui/change-log-page'; export class ChangeLogUI { + constructor(private host: string) {} ChangeLog = () => { - return ; + return ; }; static dependencies = [ComponentAspect]; static runtime = UIRuntime; - static async provider([component]: [ComponentUI]) { - const ui = new ChangeLogUI(); - const section = new ChangelogSection(); + static async provider([component]: [ComponentUI], _, __, harmony: Harmony) { + const { config } = harmony; + const host = String(config.get('teambit.harmony/bit')); + const ui = new ChangeLogUI(host); + const section = new ChangelogSection(host); component.registerRoute(section.route); component.registerWidget(section.navigationLink, section.order); diff --git a/scopes/component/changelog/ui/change-log-page.tsx b/scopes/component/changelog/ui/change-log-page.tsx index 9a42b02fa742..68c904155842 100644 --- a/scopes/component/changelog/ui/change-log-page.tsx +++ b/scopes/component/changelog/ui/change-log-page.tsx @@ -1,4 +1,5 @@ -import { ComponentContext } from '@teambit/component'; +import React, { HTMLAttributes } from 'react'; +import { ComponentContext, useComponent } from '@teambit/component'; import { H1 } from '@teambit/documenter.ui.heading'; import { Separator } from '@teambit/design.ui.separator'; import { VersionBlock } from '@teambit/component.ui.version-block'; @@ -6,19 +7,48 @@ import classNames from 'classnames'; import { MDXLayout } from '@teambit/mdx.ui.mdx-layout'; import { ExportingComponents } from '@teambit/component.instructions.exporting-components'; import { AlertCard } from '@teambit/design.ui.alert-card'; -import React, { HTMLAttributes, useContext } from 'react'; import styles from './change-log-page.module.scss'; -type ChangeLogPageProps = {} & HTMLAttributes; +type ChangeLogPageProps = { + host: string; +} & HTMLAttributes; -export function ChangeLogPage({ className }: ChangeLogPageProps) { - const component = useContext(ComponentContext); - const { logs } = component; +export function ChangeLogPage({ className, host }: ChangeLogPageProps) { + const componentContext = React.useContext(ComponentContext); + const { component, loading, componentLogs } = useComponent(host, componentContext?.id.toString(), { + logFilters: { + log: { + logLimit: 15, + logOffset: 0, + }, + }, + }); + const { loadMoreLogs, hasMoreLogs: hasMore } = componentLogs || {}; + const logs = component?.logs ?? []; - if (!logs) return null; + const observer = React.useRef(); + const handleLoadMore = async () => { + await loadMoreLogs?.(); + }; - if (logs.length === 0) { + const lastLogRef = React.useCallback( + (node) => { + if (loading) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + handleLoadMore().catch(() => {}); + } + }); + if (node) observer.current.observe(node); + }, + [loading, hasMore] + ); + + if (loading) return null; + + if (logs?.length === 0) { return (

History

@@ -42,16 +72,17 @@ export function ChangeLogPage({ className }: ChangeLogPageProps) {

History

- {logs.map((snap, index) => { - const isLatest = component.latest === snap.tag || component.latest === snap.hash; - const isCurrent = component.version === snap.tag || component.version === snap.hash; + {(logs || []).map((snap, index) => { + const isLatest = component?.latest === snap.tag || component?.latest === snap.hash; + const isCurrent = component?.version === snap.tag || component?.version === snap.hash; return ( ); })} diff --git a/scopes/component/component/component-factory.ts b/scopes/component/component/component-factory.ts index 7b2437dffa3b..2adb3d40d316 100644 --- a/scopes/component/component/component-factory.ts +++ b/scopes/component/component/component-factory.ts @@ -115,7 +115,16 @@ export interface ComponentFactory { */ getGraphIds(ids?: ComponentID[], shouldThrowOnMissingDep?: boolean): Promise; - getLogs(id: ComponentID, shortHash?: boolean, startsFrom?: string): Promise; + getLogs( + id: ComponentID, + shortHash?: boolean, + head?: string, + startFrom?: string, + stopAt?: string, + startFromOffset?: number, + stopAtOffset?: number, + type?: 'tag' | 'snap' + ): Promise; /** * returns a specific state of a component by hash or semver. diff --git a/scopes/component/component/component.graphql.ts b/scopes/component/component/component.graphql.ts index 1dc97ec2a591..1c3cc4e78875 100644 --- a/scopes/component/component/component.graphql.ts +++ b/scopes/component/component/component.graphql.ts @@ -115,6 +115,14 @@ export function componentSchema(componentExtension: ComponentMain) { start traversing logs from the fetched component's head """ takeHeadFromComponent: Boolean + """ + start slicing logs from this version + """ + startFrom: String + """ + end slicing logs until this version + """ + until: String ): [LogEntry]! aspects(include: [String]): [Aspect] @@ -194,12 +202,14 @@ export function componentSchema(componentExtension: ComponentMain) { logs: async ( component: Component, filter?: { - type?: string; + type?: 'tag' | 'snap'; offset?: number; limit?: number; head?: string; sort?: string; takeHeadFromComponent: boolean; + startFrom?: string; + until?: string; } ) => { let head = filter?.head; diff --git a/scopes/component/component/component.ts b/scopes/component/component/component.ts index 77063bf01935..0505e8bced7f 100644 --- a/scopes/component/component/component.ts +++ b/scopes/component/component/component.ts @@ -5,7 +5,6 @@ import { ComponentID } from '@teambit/component-id'; import { BitError } from '@teambit/bit-error'; import { BuildStatus } from '@teambit/legacy/dist/constants'; -import { slice } from 'lodash'; import { ComponentFactory } from './component-factory'; import ComponentFS from './component-fs'; // import { NothingToSnap } from './exceptions'; @@ -114,25 +113,20 @@ export class Component implements IComponent { return this.state.aspects.get(id)?.serialize(); } - async getLogs(filter?: { type?: string; offset?: number; limit?: number; head?: string; sort?: string }) { - const allLogs = await this.factory.getLogs(this.id, false, filter?.head); + async getLogs(filter?: { + type?: 'tag' | 'snap'; + offset?: number; + limit?: number; + head?: string; + sort?: string; + startFrom?: string; + until?: string; + }) { + const { type, limit, offset, sort, head, startFrom, until } = filter || {}; - if (!filter) return allLogs; + const filteredLogs = await this.factory.getLogs(this.id, false, head, startFrom, until, offset, limit, type); - const { type, limit, offset, sort } = filter; - - const typeFilter = (snap) => { - if (type === 'tag') return snap.tag; - if (type === 'snap') return !snap.tag; - return true; - }; - - let filteredLogs = (type && allLogs.filter(typeFilter)) || allLogs; - if (sort !== 'asc') filteredLogs = filteredLogs.reverse(); - - if (limit) { - filteredLogs = slice(filteredLogs, offset, limit + (offset || 0)); - } + if (sort !== 'asc') filteredLogs.reverse(); return filteredLogs; } diff --git a/scopes/component/component/get-component-opts.ts b/scopes/component/component/get-component-opts.ts index 995c910e796c..ffbc6bcb1918 100644 --- a/scopes/component/component/get-component-opts.ts +++ b/scopes/component/component/get-component-opts.ts @@ -1,7 +1,6 @@ import React from 'react'; import { RouteProps } from 'react-router-dom'; -import type { UseComponentType } from './ui/use-component'; -import { Filters } from './ui/use-component-query'; +import type { Filters, UseComponentType } from './ui/use-component'; export type GetComponentsOptions = { useComponent?: UseComponentType; diff --git a/scopes/component/component/ui/component.tsx b/scopes/component/component/ui/component.tsx index c1eb8ecc9d6c..642515d35737 100644 --- a/scopes/component/component/ui/component.tsx +++ b/scopes/component/component/ui/component.tsx @@ -4,13 +4,12 @@ import flatten from 'lodash.flatten'; import { RouteSlot, SlotRouter } from '@teambit/ui-foundation.ui.react-router.slot-router'; import { SlotRegistry } from '@teambit/harmony'; import { isFunction } from 'lodash'; -import styles from './component.module.scss'; +import { ComponentID } from '@teambit/component-id'; import { ComponentProvider, ComponentDescriptorProvider } from './context'; -import { useComponent as useComponentQuery, UseComponentType } from './use-component'; +import { Filters, useComponent as useComponentQuery, UseComponentType, useIdFromLocation } from './use-component'; import { ComponentModel } from './component-model'; -import { useIdFromLocation } from './use-component-from-location'; -import { ComponentID } from '..'; -import { Filters } from './use-component-query'; + +import styles from './component.module.scss'; export type ComponentPageSlot = SlotRegistry; export type ComponentPageElement = { @@ -53,16 +52,29 @@ export function Component({ const _componentIdStr = getComponentIdStr(componentIdStr); const componentId = _componentIdStr ? ComponentID.fromString(_componentIdStr) : undefined; const resolvedComponentIdStr = path || idFromLocation; - const useComponentOptions = { - logFilters: useComponentFilters?.(), - customUseComponent: useComponent, - }; + const componentFiltersFromProps = useComponentFilters?.() || {}; + + const useComponentOptions = componentFiltersFromProps.loading + ? { + logFilters: { + ...componentFiltersFromProps, + }, + customUseComponent: useComponent, + } + : { + logFilters: { + ...componentFiltersFromProps, + log: { logLimit: 3, ...componentFiltersFromProps.log }, + }, + customUseComponent: useComponent, + }; const { component, componentDescriptor, error } = useComponentQuery( host, componentId?.toString() || idFromLocation, useComponentOptions ); + // trigger onComponentChange when component changes useEffect(() => onComponentChange?.(component), [component]); // cleanup when unmounting component @@ -72,7 +84,7 @@ export function Component({ const before = useMemo(() => pageItems.filter((x) => x.type === 'before').map((x) => x.content), [pageItems]); const after = useMemo(() => pageItems.filter((x) => x.type === 'after').map((x) => x.content), [pageItems]); - if (error) return error.renderError(); + if (error) return error?.renderError(); if (!component) return
; return ( diff --git a/scopes/component/component/ui/index.ts b/scopes/component/component/ui/index.ts index bcaf819bbbcc..53573ae7cf2a 100644 --- a/scopes/component/component/ui/index.ts +++ b/scopes/component/component/ui/index.ts @@ -3,7 +3,14 @@ export { Component } from './component'; export { ConsumeMethodSlot, ComponentMenu, VersionRelatedDropdowns } from './menu'; export { ComponentModel, ComponentModelProps } from './component-model'; export { ComponentContext, ComponentProvider } from './context'; -export { useComponent } from './use-component'; export { TopBarNav } from './top-bar-nav'; -export { componentIdFields, componentOverviewFields, componentFields } from './use-component-query'; -export { useIdFromLocation } from './use-component-from-location'; +export { + componentIdFields, + componentOverviewFields, + componentFields, + useIdFromLocation, + useComponent, + useComponentLogs, + useComponentQuery, +} from './use-component'; +export type { ComponentQueryResult } from './use-component'; diff --git a/scopes/component/component/ui/menu/menu.tsx b/scopes/component/component/ui/menu/menu.tsx index e5a7bcaefcc1..cb9241a391d8 100644 --- a/scopes/component/component/ui/menu/menu.tsx +++ b/scopes/component/component/ui/menu/menu.tsx @@ -1,25 +1,23 @@ import { Routes, Route } from 'react-router-dom'; import { MainDropdown, MenuItemSlot } from '@teambit/ui-foundation.ui.main-dropdown'; import { VersionDropdown } from '@teambit/component.ui.version-dropdown'; -import { FullLoader } from '@teambit/ui-foundation.ui.full-loader'; import type { ConsumeMethod } from '@teambit/ui-foundation.ui.use-box.menu'; import { useLocation } from '@teambit/base-react.navigation.link'; -import { flatten, groupBy, compact, isFunction } from 'lodash'; +import { flatten, groupBy, isFunction } from 'lodash'; import classnames from 'classnames'; import React, { useMemo } from 'react'; import { UseBoxDropdown } from '@teambit/ui-foundation.ui.use-box.dropdown'; import { useLanes } from '@teambit/lanes.hooks.use-lanes'; import { LaneModel } from '@teambit/lanes.ui.models.lanes-model'; import { Menu as ConsumeMethodsMenu } from '@teambit/ui-foundation.ui.use-box.menu'; -import { LegacyComponentLog } from '@teambit/legacy-component-log'; +import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query'; +import { ComponentID } from '@teambit/component-id'; +import * as semver from 'semver'; import type { ComponentModel } from '../component-model'; -import { useComponent as useComponentQuery, UseComponentType } from '../use-component'; +import { Filters, useComponent as useComponentQuery, UseComponentType, useIdFromLocation } from '../use-component'; import { CollapsibleMenuNav } from './menu-nav'; import styles from './menu.module.scss'; import { OrderedNavigationSlot, ConsumeMethodSlot } from './nav-plugin'; -import { useIdFromLocation } from '../use-component-from-location'; -import { ComponentID } from '../..'; -import { Filters } from '../use-component-query'; export type MenuProps = { className?: string; @@ -54,9 +52,9 @@ export type MenuProps = { useComponent?: UseComponentType; - path?: string; - useComponentFilters?: () => Filters; + + path?: string; }; function getComponentIdStr(componentIdStr?: string | (() => string | undefined)): string | undefined { if (isFunction(componentIdStr)) return componentIdStr(); @@ -83,22 +81,21 @@ export function ComponentMenu({ const _componentIdStr = getComponentIdStr(componentIdStr); const componentId = _componentIdStr ? ComponentID.fromString(_componentIdStr) : undefined; const resolvedComponentIdStr = path || idFromLocation; - - const useComponentOptions = { - logFilters: useComponentFilters?.(), - customUseComponent: useComponent, - }; - - const { component } = useComponentQuery(host, componentId?.toString() || idFromLocation, useComponentOptions); const mainMenuItems = useMemo(() => groupBy(flatten(menuItemSlot.values()), 'category'), [menuItemSlot]); - - if (!component) return ; + const { loading, ...componentFiltersFromProps } = useComponentFilters?.() || {}; const RightSide = (
{RightNode || ( <> - + )} @@ -122,56 +119,103 @@ export function ComponentMenu({ ); } +export type VersionRelatedDropdownsProps = { + consumeMethods?: ConsumeMethodSlot; + className?: string; + host: string; + componentFilters?: Filters; + loading?: boolean; + useComponent?: UseComponentType; + componentId?: string; +}; + export function VersionRelatedDropdowns({ - component, + componentId, + componentFilters: componentFiltersFromProps = {}, + useComponent, consumeMethods, className, + loading: loadingFromProps, host, -}: { - component: ComponentModel; - consumeMethods?: ConsumeMethodSlot; - className?: string; - host: string; -}) { +}: VersionRelatedDropdownsProps) { + const query = useQuery(); + const componentVersion = query.get('version'); + const isTag = componentVersion ? semver.valid(componentVersion) : undefined; + const isSnap = componentVersion ? !isTag : undefined; + + // initially fetch just the component data + const initialFetchOptions = React.useMemo( + () => ({ + logFilters: { + ...componentFiltersFromProps, + log: { + logLimit: 3, + ...componentFiltersFromProps.log, + }, + }, + skip: loadingFromProps, + customUseComponent: useComponent, + }), + [loadingFromProps, componentFiltersFromProps, componentVersion] + ); + + const { component, loading: loadingComponent } = useComponentQuery(host, componentId, initialFetchOptions); + + const loading = React.useMemo(() => loadingComponent || loadingFromProps, [loadingComponent, loadingFromProps]); + + const useVersions = React.useCallback(() => { + const componentWithLogsOptions = { + logFilters: { + fetchLogsByTypeSeparately: true, + ...componentFiltersFromProps, + snapLog: { + logLimit: 10, + logStartFrom: isSnap ? componentVersion ?? undefined : undefined, + logOffset: isSnap ? -3 : undefined, + ...componentFiltersFromProps.snapLog, + }, + tagLog: { + logLimit: 10, + logStartFrom: isTag ? componentVersion ?? undefined : undefined, + logOffset: isTag ? -3 : undefined, + ...componentFiltersFromProps.tagLog, + }, + }, + skip: loadingFromProps, + customUseComponent: useComponent, + }; + + const { componentLogs = {}, loading: loadingLogs } = useComponentQuery(host, componentId, componentWithLogsOptions); + return { + loading: loadingLogs, + ...componentLogs, + snaps: (componentLogs.snaps || []).map((snap) => ({ ...snap, version: snap.hash })), + tags: (componentLogs.tags || []).map((tag) => ({ ...tag, version: tag.tag as string })), + }; + }, [componentVersion, isTag, isSnap, componentFiltersFromProps, loadingFromProps]); + const location = useLocation(); const { lanesModel } = useLanes(); + const lanes = component?.id + ? lanesModel?.getLanesByComponentId(component.id)?.filter((lane) => !lane.id.isDefault()) || [] + : []; const viewedLane = lanesModel?.viewedLane?.id && !lanesModel?.viewedLane?.id.isDefault() ? lanesModel.viewedLane : undefined; - const { logs } = component; const isWorkspace = host === 'teambit.workspace/workspace'; - const snaps = useMemo(() => { - return (logs || []).filter((log) => !log.tag).map((snap) => ({ ...snap, version: snap.hash })); - }, [logs]); - - const tags = useMemo(() => { - const tagLookup = new Map(); - (logs || []) - .filter((log) => log.tag) - .forEach((tag) => { - tagLookup.set(tag?.tag as string, tag); - }); - return compact( - component.tags - ?.toArray() - .reverse() - .map((tag) => tagLookup.get(tag.version.version)) - ).map((tag) => ({ ...tag, version: tag.tag as string })); - }, [logs]); - - const isNew = snaps.length === 0 && tags.length === 0; - - const lanes = lanesModel?.getLanesByComponentId(component.id)?.filter((lane) => !lane.id.isDefault()) || []; + const isNew = component?.logs?.length === 0; + const localVersion = isWorkspace && !isNew && (!viewedLane || lanesModel?.isViewingCurrentLane()); const currentVersion = - isWorkspace && !isNew && !location?.search.includes('version') ? 'workspace' : component.version; + isWorkspace && !isNew && !location?.search.includes('version') ? 'workspace' : component?.version ?? ''; const methods = useConsumeMethods(component, consumeMethods, viewedLane); + return ( <> - {consumeMethods && tags.length > 0 && ( + {consumeMethods && (component?.tags?.size ?? 0) > 0 && component?.id && ( )} flatten(consumeMethods?.values()) diff --git a/scopes/component/component/ui/use-component-query.ts b/scopes/component/component/ui/use-component-query.ts deleted file mode 100644 index 328c95becf21..000000000000 --- a/scopes/component/component/ui/use-component-query.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { useMemo, useEffect, useRef } from 'react'; -import { gql } from '@apollo/client'; -import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; -import { ComponentID, ComponentIdObj } from '@teambit/component-id'; -import { ComponentDescriptor } from '@teambit/component-descriptor'; - -import { ComponentModel } from './component-model'; -import { ComponentError } from './component-error'; - -export const componentIdFields = gql` - fragment componentIdFields on ComponentID { - name - version - scope - } -`; - -export const componentOverviewFields = gql` - fragment componentOverviewFields on Component { - id { - ...componentIdFields - } - aspects(include: ["teambit.preview/preview", "teambit.envs/envs"]) { - # 'id' property in gql refers to a *global* identifier and used for caching. - # this makes aspect data cache under the same key, even when they are under different components. - # renaming the property fixes that. - id - data - } - elementsUrl - description - deprecation { - isDeprecate - newId - } - labels - displayName - server { - env - url - host - basePath - } - buildStatus - env { - id - icon - } - size { - compressedTotal - } - preview { - includesEnvTemplate - legacyHeader - isScaling - skipIncludes - } - compositions { - identifier - displayName - } - } - ${componentIdFields} -`; - -export const componentFields = gql` - fragment componentFields on Component { - id { - ...componentIdFields - } - ...componentOverviewFields - packageName - latest - compositions { - identifier - displayName - } - tags { - version - } - logs(type: $logType, offset: $logOffset, limit: $logLimit, head: $logHead, sort: $logSort) { - id - message - username - email - date - hash - tag - } - } - ${componentIdFields} - ${componentOverviewFields} -`; - -const GET_COMPONENT = gql` - query Component( - $id: String! - $extensionId: String! - $logType: String - $logOffset: Int - $logLimit: Int - $logHead: String - $logSort: String - ) { - getHost(id: $extensionId) { - id # used for GQL caching - get(id: $id) { - ...componentFields - } - } - } - ${componentFields} -`; - -const SUB_SUBSCRIPTION_ADDED = gql` - subscription OnComponentAdded($logType: String, $logOffset: Int, $logLimit: Int, $logHead: String, $logSort: String) { - componentAdded { - component { - ...componentFields - } - } - } - ${componentFields} -`; - -const SUB_COMPONENT_CHANGED = gql` - subscription OnComponentChanged( - $logType: String - $logOffset: Int - $logLimit: Int - $logHead: String - $logSort: String - ) { - componentChanged { - component { - ...componentFields - } - } - } - ${componentFields} -`; - -const SUB_COMPONENT_REMOVED = gql` - subscription OnComponentRemoved { - componentRemoved { - componentIds { - ...componentIdFields - } - } - } - ${componentIdFields} -`; -export type Filters = { - log?: { logType?: string; logOffset?: number; logLimit?: number; logHead?: string; logSort?: string }; -}; -/** provides data to component ui page, making sure both variables and return value are safely typed and memoized */ -export function useComponentQuery(componentId: string, host: string, filters?: Filters, skip?: boolean) { - const idRef = useRef(componentId); - idRef.current = componentId; - const { data, error, loading, subscribeToMore, ...rest } = useDataQuery(GET_COMPONENT, { - variables: { id: componentId, extensionId: host, ...(filters?.log || {}) }, - skip, - errorPolicy: 'all', - }); - - useEffect(() => { - // @TODO @Kutner fix subscription for scope - if (host !== 'teambit.workspace/workspace') { - return () => {}; - } - - const unsubAddition = subscribeToMore({ - document: SUB_SUBSCRIPTION_ADDED, - updateQuery: (prev, { subscriptionData }) => { - const prevComponent = prev?.getHost?.get; - const addedComponent = subscriptionData?.data?.componentAdded?.component; - - if (!addedComponent || prevComponent) return prev; - - if (idRef.current === addedComponent.id.name) { - return { - ...prev, - getHost: { - ...prev.getHost, - get: addedComponent, - }, - }; - } - - return prev; - }, - }); - - const unsubChanges = subscribeToMore({ - document: SUB_COMPONENT_CHANGED, - updateQuery: (prev, { subscriptionData }) => { - if (!subscriptionData.data) return prev; - - const prevComponent = prev?.getHost?.get; - const updatedComponent = subscriptionData?.data?.componentChanged?.component; - - const isUpdated = updatedComponent && ComponentID.isEqualObj(prevComponent?.id, updatedComponent?.id); - - if (isUpdated) { - return { - ...prev, - getHost: { - ...prev.getHost, - get: updatedComponent, - }, - }; - } - - return prev; - }, - }); - - const unsubRemoval = subscribeToMore({ - document: SUB_COMPONENT_REMOVED, - updateQuery: (prev, { subscriptionData }) => { - if (!subscriptionData.data) return prev; - - const prevComponent = prev?.getHost?.get; - const removedIds: ComponentIdObj[] | undefined = subscriptionData?.data?.componentRemoved?.componentIds; - if (!prevComponent || !removedIds?.length) return prev; - - const isRemoved = removedIds.some((removedId) => ComponentID.isEqualObj(removedId, prevComponent.id)); - - if (isRemoved) { - return { - ...prev, - getHost: { - ...prev.getHost, - get: null, - }, - }; - } - - return prev; - }, - }); - - return () => { - unsubChanges(); - unsubAddition(); - unsubRemoval(); - }; - }, []); - - const rawComponent = data?.getHost?.get; - return useMemo(() => { - const aspectList = { - entries: rawComponent?.aspects.map((aspectObject) => { - return { - ...aspectObject, - aspectId: aspectObject.id, - aspectData: aspectObject.data, - }; - }), - }; - const id = rawComponent && ComponentID.fromObject(rawComponent.id); - const componentError = - error && !data ? new ComponentError(500, error.message) : !rawComponent && !loading && new ComponentError(404); - return { - componentDescriptor: id ? ComponentDescriptor.fromObject({ id: id.toString(), aspectList }) : undefined, - component: rawComponent ? ComponentModel.from({ ...rawComponent, host }) : undefined, - error: componentError || undefined, - loading, - ...rest, - }; - }, [rawComponent, host, error]); -} diff --git a/scopes/component/component/ui/use-component/index.ts b/scopes/component/component/ui/use-component/index.ts new file mode 100644 index 000000000000..ac460d7c4b6d --- /dev/null +++ b/scopes/component/component/ui/use-component/index.ts @@ -0,0 +1,23 @@ +export { useComponent } from './use-component'; +export { + componentIdFields, + componentOverviewFields, + componentFields, + componentFieldsWithLogs, + COMPONENT_QUERY_LOG_FIELDS, + GET_COMPONENT, + GET_COMPONENT_WITH_LOGS, +} from './use-component.fragments'; +export { useIdFromLocation } from './use-component-from-location'; +export type { + LogFilter, + Filters, + UseComponentOptions, + ComponentQueryResult, + UseComponentType, + ComponentLogs, + ComponentLogsResult, +} from './use-component.model'; +export { mergeLogs } from './use-component.utils'; +export { useComponentQuery } from './use-component-query'; +export { useComponentLogs } from './use-component-logs'; diff --git a/scopes/component/component/ui/use-component-from-location.tsx b/scopes/component/component/ui/use-component/use-component-from-location.tsx similarity index 100% rename from scopes/component/component/ui/use-component-from-location.tsx rename to scopes/component/component/ui/use-component/use-component-from-location.tsx diff --git a/scopes/component/component/ui/use-component/use-component-logs.ts b/scopes/component/component/ui/use-component/use-component-logs.ts new file mode 100644 index 000000000000..ce286f69ec7c --- /dev/null +++ b/scopes/component/component/ui/use-component/use-component-logs.ts @@ -0,0 +1,301 @@ +import React, { useMemo, useRef } from 'react'; +import { LegacyComponentLog } from '@teambit/legacy-component-log'; +import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; +import { ComponentID } from '@teambit/component-id'; +import { calculateHasMoreLogs, calculateNewOffset, getOffsetValue, mergeLogs } from './use-component.utils'; +import { ComponentLogsResult, Filters } from './use-component.model'; +import { GET_COMPONENT_WITH_LOGS } from './use-component.fragments'; +import { ComponentError } from '../component-error'; + +export function useComponentLogs( + componentId: string, + host: string, + filters?: Filters, + skipFromProps?: boolean +): ComponentLogsResult { + const { + logLimit, + offsetRef, + hasMoreLogs, + tagOffsetRef, + snapOffsetRef, + hasMoreTagLogs, + hasMoreSnapLogs, + snapLogLimit, + tagLogLimit, + logOffset, + tagLogOffset, + snapLogOffset, + fetchLogsByTypeSeparately, + variables, + skip, + } = useComponentLogsInit(componentId, host, filters, skipFromProps); + + const { data, error, loading, fetchMore } = useDataQuery(GET_COMPONENT_WITH_LOGS, { + variables, + skip, + errorPolicy: 'all', + }); + + const rawComponent = data?.getHost?.get; + const rawTags: Array = rawComponent?.tagLogs ?? []; + const rawSnaps: Array = rawComponent?.snapLogs ?? []; + const rawCompLogs: Array = rawComponent?.logs ?? mergeLogs(rawTags, rawSnaps); + + offsetRef.current = useMemo( + () => calculateNewOffset(logOffset, offsetRef.current, rawCompLogs), + [rawCompLogs, fetchLogsByTypeSeparately, logOffset] + ); + + tagOffsetRef.current = useMemo( + () => calculateNewOffset(tagLogOffset, tagOffsetRef.current, rawTags), + [rawTags, fetchLogsByTypeSeparately, tagLogOffset] + ); + + snapOffsetRef.current = useMemo( + () => calculateNewOffset(snapLogOffset, snapOffsetRef.current, rawSnaps), + [rawSnaps, fetchLogsByTypeSeparately, snapLogOffset] + ); + + hasMoreLogs.current = useMemo( + () => calculateHasMoreLogs(logLimit, rawComponent, 'logs', hasMoreLogs.current), + [rawCompLogs] + ); + + hasMoreTagLogs.current = useMemo( + () => calculateHasMoreLogs(tagLogLimit, rawComponent, 'tagLogs', hasMoreTagLogs.current), + [rawTags] + ); + + hasMoreSnapLogs.current = useMemo( + () => calculateHasMoreLogs(snapLogLimit, rawComponent, 'snapLogs', hasMoreSnapLogs.current), + [rawSnaps] + ); + + const loadMoreLogs = React.useCallback( + async (backwards = false) => { + const offset = getOffsetValue(offsetRef.current, logLimit, backwards); + + if (logLimit) { + await fetchMore({ + variables: { + logOffset: offset, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + + const prevComponent = prev.getHost.get; + const fetchedComponent = fetchMoreResult.getHost.get; + if (fetchedComponent && ComponentID.isEqualObj(prevComponent.id, fetchedComponent.id)) { + const updatedLogs = mergeLogs(prevComponent.logs, fetchedComponent.logs); + if (updatedLogs.length > prevComponent.logs.length) { + offsetRef.current = fetchedComponent.logs.length + offset; + // @todo account for limit (the API gives the nearest nodes to the limit, not the exact limit) + hasMoreLogs.current = true; + } + + return { + ...prev, + getHost: { + ...prev.getHost, + get: { + ...prevComponent, + logs: updatedLogs, + }, + }, + }; + } + + return prev; + }, + }); + } + }, + [logLimit, fetchMore] + ); + + const loadMoreTags = React.useCallback( + async (backwards = false) => { + const offset = getOffsetValue(tagOffsetRef.current, tagLogLimit, backwards); + + if (tagLogLimit) { + await fetchMore({ + variables: { + tagLogOffset: offset, + tagLogLimit, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + + const prevComponent = prev.getHost.get; + const fetchedComponent = fetchMoreResult.getHost.get; + const prevTags = prevComponent.tagLogs; + const fetchedTags = fetchedComponent.tagLogs ?? []; + if (fetchedComponent && ComponentID.isEqualObj(prevComponent.id, fetchedComponent.id)) { + const updatedTags = mergeLogs(prevTags, fetchedTags); + if (updatedTags.length > prevTags.length) { + tagOffsetRef.current = fetchedTags.length + offset; + // @todo account for limit (the API gives the nearest nodes to the limit, not the exact limit) + hasMoreTagLogs.current = true; + } + + return { + ...prev, + getHost: { + ...prev.getHost, + get: { + ...prevComponent, + tagLogs: updatedTags, + }, + }, + }; + } + + return prev; + }, + }); + } + }, + [tagLogLimit, fetchMore] + ); + + const loadMoreSnaps = React.useCallback( + async (backwards = false) => { + const offset = getOffsetValue(snapOffsetRef.current, snapLogLimit, backwards); + + if (snapLogLimit) { + await fetchMore({ + variables: { + snapLogOffset: offset, + snapLogLimit, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + + const prevComponent = prev.getHost.get; + const prevSnaps = prevComponent.snapLogs ?? []; + const fetchedComponent = fetchMoreResult.getHost.get; + const fetchedSnaps = fetchedComponent.snapLogs ?? []; + if (fetchedComponent && ComponentID.isEqualObj(prevComponent.id, fetchedComponent.id)) { + const updatedSnaps = mergeLogs(prevSnaps, fetchedSnaps); + if (updatedSnaps.length > prevSnaps.length) { + snapOffsetRef.current = fetchedSnaps.length + offset; + // @todo account for limit (the API gives the nearest nodes to the limit, not the exact limit) + hasMoreSnapLogs.current = true; + } + + return { + ...prev, + getHost: { + ...prev.getHost, + get: { + ...prevComponent, + snapLogs: updatedSnaps, + }, + }, + }; + } + + return prev; + }, + }); + } + }, + [snapLogLimit, fetchMore] + ); + + const componentError = + error && !data + ? new ComponentError(500, error.message) + : (!rawComponent && !loading && new ComponentError(404)) || undefined; + + return { + loading, + error: componentError, + componentLogs: { + logs: rawCompLogs, + snaps: rawSnaps, + tags: rawTags, + hasMoreLogs: hasMoreLogs.current, + hasMoreTags: hasMoreTagLogs.current, + hasMoreSnaps: hasMoreSnapLogs.current, + loadMoreLogs, + loadMoreTags, + loadMoreSnaps, + loading, + }, + }; +} + +export function useComponentLogsInit(componentId: string, host: string, filters?: Filters, skip?: boolean) { + const { fetchLogsByTypeSeparately = false, log, tagLog, snapLog } = filters || {}; + const { + logHead: tagLogHead, + logOffset: tagLogOffset, + logSort: tagLogSort, + logLimit: tagLogLimit, + takeHeadFromComponent: tagLogTakeHeadFromComponent, + logStartFrom: tagStartFrom, + logUntil: tagUntil, + } = tagLog || {}; + const { logHead, logOffset, logSort, logLimit, takeHeadFromComponent, logType, logStartFrom, logUntil } = log || {}; + const { + logHead: snapLogHead, + logOffset: snapLogOffset, + logSort: snapLogSort, + logLimit: snapLogLimit, + takeHeadFromComponent: snapLogTakeHeadFromComponent, + logStartFrom: snapStartFrom, + logUntil: snapUntil, + } = snapLog || {}; + const variables = { + id: componentId, + extensionId: host, + fetchLogsByTypeSeparately, + snapLogOffset: getOffsetValue(snapLogOffset, snapLogLimit), + tagLogOffset: getOffsetValue(tagLogOffset, tagLogLimit), + logOffset: getOffsetValue(logOffset, logLimit), + logLimit, + snapLogLimit, + tagLogLimit, + logType, + logHead, + snapLogHead, + tagLogHead, + logStartFrom, + snapStartFrom, + tagStartFrom, + logUntil, + snapUntil, + tagUntil, + logSort, + snapLogSort, + tagLogSort, + takeHeadFromComponent, + snapLogTakeHeadFromComponent, + tagLogTakeHeadFromComponent, + }; + const offsetRef = useRef(logOffset); + const tagOffsetRef = useRef(tagLogOffset); + const snapOffsetRef = useRef(snapLogOffset); + const hasMoreLogs = useRef(undefined); + const hasMoreTagLogs = useRef(undefined); + const hasMoreSnapLogs = useRef(undefined); + return { + logOffset, + variables, + offsetRef, + tagOffsetRef, + snapOffsetRef, + hasMoreLogs, + hasMoreTagLogs, + hasMoreSnapLogs, + logLimit, + snapLogLimit, + tagLogLimit, + fetchLogsByTypeSeparately, + tagLogOffset, + snapLogOffset, + skip, + }; +} diff --git a/scopes/component/component/ui/use-component/use-component-query.ts b/scopes/component/component/ui/use-component/use-component-query.ts new file mode 100644 index 000000000000..953c7dda2551 --- /dev/null +++ b/scopes/component/component/ui/use-component/use-component-query.ts @@ -0,0 +1,189 @@ +import { useMemo, useEffect, useRef } from 'react'; +import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; +import { ComponentID, ComponentIdObj } from '@teambit/component-id'; +import { ComponentDescriptor } from '@teambit/component-descriptor'; +import { ComponentModel } from '../component-model'; +import { ComponentQueryResult, Filters } from './use-component.model'; +import { + GET_COMPONENT, + SUB_COMPONENT_CHANGED, + SUB_COMPONENT_REMOVED, + SUB_SUBSCRIPTION_ADDED, +} from './use-component.fragments'; +import { useComponentLogs } from './use-component-logs'; +import { ComponentError } from '../component-error'; + +/** provides data to component ui page, making sure both variables and return value are safely typed and memoized */ +export function useComponentQuery( + componentId: string, + host: string, + filters?: Filters, + skip?: boolean +): ComponentQueryResult { + const idRef = useRef(componentId); + idRef.current = componentId; + const variables = { + id: componentId, + extensionId: host, + }; + + const { data, error, loading, subscribeToMore } = useDataQuery(GET_COMPONENT, { + variables, + skip, + errorPolicy: 'all', + }); + + const { + loading: loadingLogs, + componentLogs: { + tags, + snaps, + logs, + hasMoreLogs, + loadMoreSnaps, + loadMoreTags, + loadMoreLogs, + hasMoreSnaps, + hasMoreTags, + } = {}, + } = useComponentLogs(componentId, host, filters, skip); + + const rawComponent = data?.getHost?.get; + + useEffect(() => { + // @TODO @Kutner fix subscription for scope + if (host !== 'teambit.workspace/workspace') { + return () => {}; + } + + const unsubAddition = subscribeToMore({ + document: SUB_SUBSCRIPTION_ADDED, + updateQuery: (prev, { subscriptionData }) => { + const prevComponent = prev?.getHost?.get; + const addedComponent = subscriptionData?.data?.componentAdded?.component; + + if (!addedComponent || prevComponent) return prev; + + if (idRef.current === addedComponent.id.name) { + return { + ...prev, + getHost: { + ...prev.getHost, + get: addedComponent, + }, + }; + } + + return prev; + }, + }); + + const unsubChanges = subscribeToMore({ + document: SUB_COMPONENT_CHANGED, + updateQuery: (prev, { subscriptionData }) => { + if (!subscriptionData.data) return prev; + + const prevComponent = prev?.getHost?.get; + const updatedComponent = subscriptionData?.data?.componentChanged?.component; + + const isUpdated = updatedComponent && ComponentID.isEqualObj(prevComponent?.id, updatedComponent?.id); + + if (isUpdated) { + return { + ...prev, + getHost: { + ...prev.getHost, + get: updatedComponent, + }, + }; + } + + return prev; + }, + }); + + const unsubRemoval = subscribeToMore({ + document: SUB_COMPONENT_REMOVED, + updateQuery: (prev, { subscriptionData }) => { + if (!subscriptionData.data) return prev; + + const prevComponent = prev?.getHost?.get; + const removedIds: ComponentIdObj[] | undefined = subscriptionData?.data?.componentRemoved?.componentIds; + if (!prevComponent || !removedIds?.length) return prev; + + const isRemoved = removedIds.some((removedId) => ComponentID.isEqualObj(removedId, prevComponent.id)); + + if (isRemoved) { + return { + ...prev, + getHost: { + ...prev.getHost, + get: null, + }, + }; + } + + return prev; + }, + }); + + return () => { + unsubChanges(); + unsubAddition(); + unsubRemoval(); + }; + }, []); + + const idDepKey = rawComponent?.id + ? `${rawComponent?.id?.scope}/${rawComponent?.id?.name}@${rawComponent?.id?.version}}` + : undefined; + + const id: ComponentID | undefined = useMemo( + () => (rawComponent ? ComponentID.fromObject(rawComponent.id) : undefined), + [idDepKey] + ); + + const componentError = + error && !data + ? new ComponentError(500, error.message) + : (!rawComponent && !loading && new ComponentError(404)) || undefined; + + const component = useMemo( + () => (rawComponent ? ComponentModel.from({ ...rawComponent, host, logs }) : undefined), + [id?.toString(), logs] + ); + + const componentDescriptor = useMemo(() => { + const aspectList = { + entries: rawComponent?.aspects.map((aspectObject) => { + return { + ...aspectObject, + aspectId: aspectObject.id, + aspectData: aspectObject.data, + }; + }), + }; + + return id ? ComponentDescriptor.fromObject({ id: id.toString(), aspectList }) : undefined; + }, [id?.toString()]); + + return useMemo(() => { + return { + componentDescriptor, + component, + componentLogs: { + loading: loadingLogs, + loadMoreLogs, + loadMoreSnaps, + loadMoreTags, + hasMoreLogs, + hasMoreSnaps, + hasMoreTags, + snaps, + tags, + }, + error: componentError || undefined, + loading, + }; + }, [host, component, componentDescriptor, componentError, hasMoreLogs, hasMoreSnaps, hasMoreTags, snaps, tags]); +} diff --git a/scopes/component/component/ui/use-component/use-component.fragments.ts b/scopes/component/component/ui/use-component/use-component.fragments.ts new file mode 100644 index 000000000000..59e05424f1ce --- /dev/null +++ b/scopes/component/component/ui/use-component/use-component.fragments.ts @@ -0,0 +1,223 @@ +import { gql } from '@apollo/client'; + +export const componentIdFields = gql` + fragment componentIdFields on ComponentID { + name + version + scope + } +`; + +export const componentOverviewFields = gql` + fragment componentOverviewFields on Component { + id { + ...componentIdFields + } + aspects(include: ["teambit.preview/preview", "teambit.envs/envs"]) { + # 'id' property in gql refers to a *global* identifier and used for caching. + # this makes aspect data cache under the same key, even when they are under different components. + # renaming the property fixes that. + id + data + } + elementsUrl + description + deprecation { + isDeprecate + newId + } + labels + displayName + server { + env + url + host + basePath + } + buildStatus + env { + id + icon + } + size { + compressedTotal + } + preview { + includesEnvTemplate + legacyHeader + isScaling + skipIncludes + } + compositions { + identifier + displayName + } + } + ${componentIdFields} +`; + +export const componentFields = gql` + fragment componentFields on Component { + ...componentOverviewFields + packageName + latest + compositions { + identifier + displayName + } + tags { + version + } + } + ${componentOverviewFields} +`; + +export const componentFieldsWithLogs = gql` + fragment componentFieldWithLogs on Component { + id { + ...componentIdFields + } + logs( + type: $logType + offset: $logOffset + limit: $logLimit + sort: $logSort + takeHeadFromComponent: $takeHeadFromComponent + head: $logHead + startFrom: $logStartFrom + until: $logUntil + ) @skip(if: $fetchLogsByTypeSeparately) { + id + message + username + email + date + hash + tag + } + tagLogs: logs( + type: "tag" + offset: $tagLogOffset + limit: $tagLogLimit + sort: $tagLogSort + takeHeadFromComponent: $tagTakeHeadFromComponent + head: $tagLogHead + startFrom: $tagStartFrom + until: $tagUntil + ) @include(if: $fetchLogsByTypeSeparately) { + id + message + username + email + date + hash + tag + } + snapLogs: logs( + type: "snap" + offset: $snapLogOffset + limit: $snapLogLimit + sort: $snapLogSort + takeHeadFromComponent: $snapTakeHeadFromComponent + head: $snapLogHead + startFrom: $snapStartFrom + until: $snapUntil + ) @include(if: $fetchLogsByTypeSeparately) { + id + message + username + email + date + hash + tag + } + } + ${componentIdFields} +`; + +export const COMPONENT_QUERY_LOG_FIELDS = ` + $logOffset: Int + $logLimit: Int + $logType: String + $logHead: String + $logSort: String + $logStartFrom: String + $logUntil: String + $tagLogOffset: Int + $tagLogLimit: Int + $tagLogHead: String + $tagLogSort: String + $tagStartFrom: String + $tagUntil: String + $snapLogOffset: Int + $snapLogLimit: Int + $snapLogHead: String + $snapLogSort: String + $snapStartFrom: String + $snapUntil: String + $takeHeadFromComponent: Boolean + $tagTakeHeadFromComponent: Boolean + $snapTakeHeadFromComponent: Boolean + $fetchLogsByTypeSeparately: Boolean! +`; + +export const GET_COMPONENT = gql` + query Component($extensionId: String!, $id: String!) { + getHost(id: $extensionId) { + id # used for GQL caching + get(id: $id) { + ...componentFields + } + } + } + ${componentFields} +`; + +export const GET_COMPONENT_WITH_LOGS = gql` + query Component( + $extensionId: String! + $id: String! + ${COMPONENT_QUERY_LOG_FIELDS} + ) { + getHost(id: $extensionId) { + id # used for GQL caching + get(id: $id) { + ...componentFieldWithLogs + } + } + } + ${componentFieldsWithLogs} +`; + +export const SUB_SUBSCRIPTION_ADDED = gql` + subscription OnComponentAdded { + componentAdded { + component { + ...componentFields + } + } + } + ${componentFields} +`; + +export const SUB_COMPONENT_CHANGED = gql` + subscription OnComponentChanged { + componentChanged { + component { + ...componentFields + } + } + } + ${componentFields} +`; + +export const SUB_COMPONENT_REMOVED = gql` + subscription OnComponentRemoved { + componentRemoved { + componentIds { + ...componentIdFields + } + } + } + ${componentIdFields} +`; diff --git a/scopes/component/component/ui/use-component/use-component.model.ts b/scopes/component/component/ui/use-component/use-component.model.ts new file mode 100644 index 000000000000..cfd208c066b9 --- /dev/null +++ b/scopes/component/component/ui/use-component/use-component.model.ts @@ -0,0 +1,58 @@ +import { ComponentDescriptor } from '@teambit/component-descriptor'; +import { LegacyComponentLog } from '@teambit/legacy-component-log'; +import { ComponentError } from '../component-error'; +import { ComponentModel } from '../component-model'; + +export type LogFilter = { + logOffset?: number; + logLimit?: number; + logHead?: string; + logStartFrom?: string; + logUntil?: string; + logSort?: string; + takeHeadFromComponent?: boolean; +}; + +export type Filters = { + log?: LogFilter & { logType?: string }; + tagLog?: LogFilter; + snapLog?: LogFilter; + fetchLogsByTypeSeparately?: boolean; + loading?: boolean; +}; + +export type UseComponentOptions = { + version?: string; + logFilters?: Filters; + customUseComponent?: UseComponentType; + skip?: boolean; +}; + +export type ComponentQueryResult = { + component?: ComponentModel; + componentDescriptor?: ComponentDescriptor; + componentLogs?: ComponentLogs; + loading?: boolean; + error?: ComponentError; +}; + +export type ComponentLogsResult = { + componentLogs?: ComponentLogs; + error?: ComponentError; + loading?: boolean; +}; + +export type ComponentLogs = { + snaps?: LegacyComponentLog[]; + tags?: LegacyComponentLog[]; + logs?: LegacyComponentLog[]; + loading?: boolean; + hasMoreLogs?: boolean; + hasMoreSnaps?: boolean; + hasMoreTags?: boolean; + loadMoreLogs?: (backwards?: boolean) => Promise; + loadMoreTags?: (backwards?: boolean) => Promise; + loadMoreSnaps?: (backwards?: boolean) => Promise; +}; + +export type UseComponentType = (id: string, host: string, filters?: Filters, skip?: boolean) => ComponentQueryResult; diff --git a/scopes/component/component/ui/use-component.tsx b/scopes/component/component/ui/use-component/use-component.tsx similarity index 50% rename from scopes/component/component/ui/use-component.tsx rename to scopes/component/component/ui/use-component/use-component.tsx index 9db3b6596a1f..ea7161d291d3 100644 --- a/scopes/component/component/ui/use-component.tsx +++ b/scopes/component/component/ui/use-component/use-component.tsx @@ -1,25 +1,8 @@ import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query'; -import { ComponentDescriptor } from '@teambit/component-descriptor'; -import { ComponentModel } from './component-model'; -import { ComponentError } from './component-error'; -import { Filters, useComponentQuery } from './use-component-query'; +import { useComponentQuery } from './use-component-query'; +import { ComponentQueryResult, UseComponentOptions } from './use-component.model'; -export type Component = { - component?: ComponentModel; - error?: ComponentError; - componentDescriptor?: ComponentDescriptor; - loading?: boolean; -}; -export type UseComponentOptions = { - version?: string; - logFilters?: Filters; - customUseComponent?: UseComponentType; - skip?: boolean; -}; - -export type UseComponentType = (id: string, host: string, filters?: Filters, skip?: boolean) => Component; - -export function useComponent(host: string, id?: string, options?: UseComponentOptions): Component { +export function useComponent(host: string, id?: string, options?: UseComponentOptions): ComponentQueryResult { const query = useQuery(); const { version, logFilters, customUseComponent, skip } = options || {}; const componentVersion = (version || query.get('version')) ?? undefined; diff --git a/scopes/component/component/ui/use-component/use-component.utils.ts b/scopes/component/component/ui/use-component/use-component.utils.ts new file mode 100644 index 000000000000..3dbdf3224dc1 --- /dev/null +++ b/scopes/component/component/ui/use-component/use-component.utils.ts @@ -0,0 +1,92 @@ +import { LegacyComponentLog } from '@teambit/legacy-component-log'; + +export function mergeLogs(logs1: LegacyComponentLog[] = [], logs2: LegacyComponentLog[] = []): LegacyComponentLog[] { + const logMap = new Map(); + const result: LegacyComponentLog[] = []; + + let index1 = 0; + let index2 = 0; + + while (index1 < logs1.length && index2 < logs2.length) { + if (Number(logs1[index1].date) >= Number(logs2[index2].date)) { + if (!logMap.has(logs1[index1].hash)) { + logMap.set(logs1[index1].hash, logs1[index1]); + result.push(logs1[index1]); + } + index1 += 1; + } else { + if (!logMap.has(logs2[index2].hash)) { + logMap.set(logs2[index2].hash, logs2[index2]); + result.push(logs2[index2]); + } + index2 += 1; + } + } + + while (index1 < logs1.length) { + if (!logMap.has(logs1[index1].hash)) { + logMap.set(logs1[index1].hash, logs1[index1]); + result.push(logs1[index1]); + } + index1 += 1; + } + + while (index2 < logs2.length) { + if (!logMap.has(logs2[index2].hash)) { + logMap.set(logs2[index2].hash, logs2[index2]); + result.push(logs2[index2]); + } + index2 += 1; + } + + return result; +} + +export function getOffsetValue(offset, limit, backwards = false) { + if (offset !== undefined) { + return backwards ? -(offset + limit) : offset; + } + if (limit !== undefined) { + return 0; + } + return undefined; +} +/** + * Calculates the new offset based on initial offset, current offset, and the number of logs. + * + * @param {boolean} [fetchLogsByTypeSeparately] A flag to determine if logs are fetched by type separately. + * @param {number} [initialOffset] The initial offset. + * @param {number} [currentOffset] The current offset. + * @param {any[]} [logs=[]] The array of logs. + * + * @returns {number | undefined} - new offset + */ +export function calculateNewOffset(initialOffset = 0, currentOffset = 0, logs: any[] = []): number | undefined { + const logCount = logs.length; + + if (initialOffset !== currentOffset && logCount + initialOffset >= currentOffset) return currentOffset; + return logCount + initialOffset; +} + +/** + * Calculate the availability of more logs. + * + * @param {number | undefined} logLimit - The limit for the logs. + * @param {any} rawComponent - The raw component object containing logs. + * @param {string} logType - Type of log ('logs', 'tagLogs', 'snapLogs'). + * @param {boolean | undefined} currentHasMoreLogs - Current state of having more logs. + * + * @returns {boolean | undefined} - Whether there are more logs available. + */ +export function calculateHasMoreLogs( + // @todo account for negative offset and limit (the API gives the nearest nodes to the limit, not the exact limit) + logLimit?: number, + rawComponent?: any, + logType = 'logs', + currentHasMoreLogs?: boolean +): boolean | undefined { + if (!logLimit) return false; + if (rawComponent === undefined) return undefined; + if (currentHasMoreLogs === undefined) return !!rawComponent?.[logType]?.length; + return currentHasMoreLogs; +} diff --git a/scopes/component/ui/version-block/version-block.tsx b/scopes/component/ui/version-block/version-block.tsx index 90572439b554..a99bf437cb6d 100644 --- a/scopes/component/ui/version-block/version-block.tsx +++ b/scopes/component/ui/version-block/version-block.tsx @@ -17,11 +17,11 @@ export type VersionBlockProps = { snap: LegacyComponentLog; isCurrent: boolean; } & HTMLAttributes; -/** - * change log section - * @name VersionBlock - */ -export function VersionBlock({ isLatest, className, snap, componentId, isCurrent, ...rest }: VersionBlockProps) { + +function _VersionBlock( + { isLatest, className, snap, componentId, isCurrent, ...rest }: VersionBlockProps, + ref: React.ForwardedRef +) { const { username, email, message, tag, hash, date } = snap; const { lanesModel } = useLanes(); const currentLaneUrl = lanesModel?.viewedLane @@ -37,7 +37,7 @@ export function VersionBlock({ isLatest, className, snap, componentId, isCurrent const timestamp = useMemo(() => (date ? new Date(parseInt(date)).toString() : new Date().toString()), [date]); return ( -
+
@@ -62,6 +62,11 @@ export function VersionBlock({ isLatest, className, snap, componentId, isCurrent
); } +/** + * change log section + * @name VersionBlock + */ +export const VersionBlock = React.memo(React.forwardRef(_VersionBlock)); function commitMessage(message: string) { if (!message || message === '') return
No commit message
; diff --git a/scopes/component/ui/version-dropdown/version-dropdown-placeholder.module.scss b/scopes/component/ui/version-dropdown/version-dropdown-placeholder.module.scss index 7d33937585b7..36155c16fdf6 100644 --- a/scopes/component/ui/version-dropdown/version-dropdown-placeholder.module.scss +++ b/scopes/component/ui/version-dropdown/version-dropdown-placeholder.module.scss @@ -95,3 +95,10 @@ flex: none; } } + +.loader { + color: var(--bit-bg-dent, #f6f6f6); + > span { + padding: 4px; + } +} diff --git a/scopes/component/ui/version-dropdown/version-dropdown-placeholder.tsx b/scopes/component/ui/version-dropdown/version-dropdown-placeholder.tsx index 38b4f2892a00..f1176151cd37 100644 --- a/scopes/component/ui/version-dropdown/version-dropdown-placeholder.tsx +++ b/scopes/component/ui/version-dropdown/version-dropdown-placeholder.tsx @@ -1,80 +1,86 @@ -import React, { HTMLAttributes, useMemo } from 'react'; +import React, { HTMLAttributes } from 'react'; import { Ellipsis } from '@teambit/design.ui.styles.ellipsis'; import classNames from 'classnames'; +import * as semver from 'semver'; import { Icon } from '@teambit/evangelist.elements.icon'; import { TimeAgo } from '@teambit/design.ui.time-ago'; import { UserAvatar } from '@teambit/design.ui.avatar'; -import { DropdownComponentVersion } from './version-dropdown'; +import { WordSkeleton } from '@teambit/base-ui.loaders.skeleton'; import styles from './version-dropdown-placeholder.module.scss'; export type VersionProps = { - tags: DropdownComponentVersion[]; - snaps?: DropdownComponentVersion[]; - currentVersion: string; + currentVersion?: string; + timestamp?: string | number; + author?: { + displayName?: string; + email?: string; + }; + message?: string; disabled?: boolean; + hasMoreVersions?: boolean; + loading?: boolean; } & HTMLAttributes; -const getVersionDetailFromTags = (version, tags) => tags.find((tag) => tag.tag === version); -const getVersionDetailFromSnaps = (version, snaps) => (snaps || []).find((snap) => snap.hash === version); -const getVersionDetails = (version, tags, snaps) => { - if (version === 'workspace' || version === 'new') return { version }; - return getVersionDetailFromTags(version, tags) || getVersionDetailFromSnaps(version, snaps); -}; +export function SimpleVersion({ + currentVersion, + className, + disabled, + hasMoreVersions, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + author, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + message, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + timestamp, + loading, + ...rest +}: VersionProps) { + if (loading) return ; -export function SimpleVersion({ currentVersion, className, disabled, tags, snaps }: VersionProps) { - const showArrowDown = useMemo(() => (snaps || []).concat(tags).length > 1, [tags, snaps]); - const versionDetails = useMemo(() => getVersionDetails(currentVersion, tags, snaps), [currentVersion, tags, snaps]); + const isTag = semver.valid(currentVersion); return ( -
+
{currentVersion} - {showArrowDown && } + {hasMoreVersions && }
); } -export function DetailedVersion({ currentVersion, className, disabled, snaps, tags }: VersionProps) { - const showArrowDown = useMemo(() => (snaps || []).concat(tags).length > 1, [tags, snaps]); - const versionDetails = useMemo(() => getVersionDetails(currentVersion, tags, snaps), [currentVersion, tags, snaps]); - - const timestamp = useMemo( - () => (versionDetails?.date ? new Date(parseInt(versionDetails.date)).toString() : new Date().toString()), - [versionDetails?.date] - ); - - const author = useMemo(() => { - return { - displayName: versionDetails?.username, - email: versionDetails?.email, - }; - }, [versionDetails]); +export function DetailedVersion({ + currentVersion, + className, + disabled, + hasMoreVersions, + timestamp, + author, + message, + loading, + ...rest +}: VersionProps) { + if (loading) return ; + const isTag = semver.valid(currentVersion); return ( -
- +
+ {currentVersion} - {commitMessage(versionDetails?.message)} - - + {commitMessage(message)} + + - {showArrowDown && } + {hasMoreVersions && }
); } diff --git a/scopes/component/ui/version-dropdown/version-dropdown.composition.tsx b/scopes/component/ui/version-dropdown/version-dropdown.composition.tsx index d3a34f1171c5..e6185e683322 100644 --- a/scopes/component/ui/version-dropdown/version-dropdown.composition.tsx +++ b/scopes/component/ui/version-dropdown/version-dropdown.composition.tsx @@ -8,17 +8,28 @@ const style = { display: 'flex', justifyContent: 'center', alignContent: 'center export const VersionDropdownWithOneVersion = () => { return ( - + ({ + tags: [{ version: '0.1' }], + })} + currentVersion="0.1" + /> ); }; export const VersionDropdownWithMultipleVersions = () => { const versions = ['0.3', '0.2', '0.1'].map((version) => ({ version })); + return ( - + ({ + tags: [{ version: '0.1' }], + })} + currentVersion={versions[0].version} + /> ); diff --git a/scopes/component/ui/version-dropdown/version-dropdown.module.scss b/scopes/component/ui/version-dropdown/version-dropdown.module.scss index f5f46def1e0e..d8e431adac2c 100644 --- a/scopes/component/ui/version-dropdown/version-dropdown.module.scss +++ b/scopes/component/ui/version-dropdown/version-dropdown.module.scss @@ -32,6 +32,7 @@ max-height: 240px; overflow-y: scroll; padding-bottom: 8px; + position: relative; } .versionRow { @@ -103,5 +104,32 @@ } .loading { - color: var(--bit-bg-heavy, #f6f6f6); + color: var(--bit-bg-dent, #f6f6f6); +} + +.loader { + color: var(--bit-bg-dent, #f6f6f6); + > div { + padding: 8px 0px; + } +} + +.pullDownIndicator { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + background-color: #f2f2f2; + font-size: 14px; + color: #666; + transform: translateY(-100%); + transition: transform 0.3s ease-in-out; + z-index: 1; + &.active { + transform: translateY(0); + } } diff --git a/scopes/component/ui/version-dropdown/version-dropdown.spec.tsx b/scopes/component/ui/version-dropdown/version-dropdown.spec.tsx index cc8f31778b21..4f67e26029ac 100644 --- a/scopes/component/ui/version-dropdown/version-dropdown.spec.tsx +++ b/scopes/component/ui/version-dropdown/version-dropdown.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { expect } from 'chai'; import { VersionDropdownWithOneVersion, VersionDropdownWithMultipleVersions } from './version-dropdown.composition'; @@ -16,13 +16,14 @@ describe('version dropdown tests', () => { const textVersion = getByText(/^0.1$/); expect(textVersion).to.exist; }); - it('should return multiple versions', () => { - const { getByText, getAllByText } = render(); - const textVersionOne = getByText(/^0.1$/); - const textVersionTwo = getByText(/^0.2$/); - const textVersionThree = getAllByText(/^0.3$/); - expect(textVersionOne).to.exist; - expect(textVersionTwo).to.exist; - expect(textVersionThree).to.exist; + it('should not return multiple versions when mounted (lazy loading)', () => { + render(); + const textVersionOne = screen.queryByText(/^0.1$/); + const textVersionTwo = screen.queryByText(/^0.2$/); + const textVersionThree = screen.getAllByText(/^0.3$/); + + expect(textVersionOne).to.be.null; + expect(textVersionTwo).to.be.null; + expect(textVersionThree).to.have.lengthOf.at.least(1); }); }); diff --git a/scopes/component/ui/version-dropdown/version-dropdown.tsx b/scopes/component/ui/version-dropdown/version-dropdown.tsx index d2f5c95e70d7..f38a0872bc2e 100644 --- a/scopes/component/ui/version-dropdown/version-dropdown.tsx +++ b/scopes/component/ui/version-dropdown/version-dropdown.tsx @@ -17,121 +17,32 @@ export const LOCAL_VERSION = 'workspace'; export type DropdownComponentVersion = Partial & { version: string }; -export type VersionDropdownProps = { - tags: DropdownComponentVersion[]; - snaps?: DropdownComponentVersion[]; - lanes?: LaneModel[]; - localVersion?: boolean; - currentVersion: string; - currentLane?: LaneModel; - latestVersion?: string; - loading?: boolean; - overrideVersionHref?: (version: string) => string; - placeholderClassName?: string; - dropdownClassName?: string; - menuClassName?: string; - showVersionDetails?: boolean; - disabled?: boolean; - placeholderComponent?: ReactNode; -} & React.HTMLAttributes; +const VersionMenu = React.memo(_VersionMenu); -export function VersionDropdown({ - snaps, - tags, - lanes, - currentVersion, - latestVersion, - localVersion, - loading, - currentLane, - overrideVersionHref, - className, - placeholderClassName, - dropdownClassName, - menuClassName, - showVersionDetails, - disabled, - placeholderComponent = ( - - ), - ...rest -}: VersionDropdownProps) { - const [key, setKey] = useState(0); - - const singleVersion = (snaps || []).concat(tags).length < 2 && !localVersion; - - if (disabled || (singleVersion && !loading)) { - return
{placeholderComponent}
; - } - - return ( -
- open && setKey((x) => x + 1)} // to reset menu to initial state when toggling - PlaceholderComponent={({ children, ...other }) => ( -
- {children} -
- )} - placeholder={placeholderComponent} - > - {loading && } - {loading || ( - - )} -
-
- ); -} - -type VersionMenuProps = { - tags?: DropdownComponentVersion[]; - snaps?: DropdownComponentVersion[]; - lanes?: LaneModel[]; - localVersion?: boolean; - currentVersion?: string; - latestVersion?: string; - currentLane?: LaneModel; - overrideVersionHref?: (version: string) => string; - showVersionDetails?: boolean; -} & React.HTMLAttributes; - -const VERSION_TAB_NAMES = ['TAG', 'SNAP', 'LANE'] as const; - -function VersionMenu({ - tags, - snaps, - lanes, +function _VersionMenu({ currentVersion, localVersion, latestVersion, - currentLane, overrideVersionHref, showVersionDetails, + useVersions, + currentLane, + lanes, + loading: loadingFromProps, ...rest }: VersionMenuProps) { + const { + snaps, + tags, + hasMoreSnaps, + hasMoreTags, + loadMoreSnaps, + loadMoreTags, + loading: loadingVersions, + } = useVersions?.() || {}; + const VERSION_TAB_NAMES = ['TAG', 'SNAP', 'LANE'] as const; + const loading = loadingFromProps || loadingVersions; + const tabs = VERSION_TAB_NAMES.map((name) => { switch (name) { case 'SNAP': @@ -151,18 +62,126 @@ function VersionMenu({ return 0; }; - const [activeTabIndex, setActiveTab] = useState(getActiveTabIndex()); + const [activeTabIndex, setActiveTab] = React.useState(undefined); + const firstObserver = React.useRef(); + const lastObserver = React.useRef(); + const currentVersionRef = React.useRef(); + + React.useEffect(() => { + if (activeTabIndex !== undefined) return; + if (!currentLane) return; + if (tabs.length === 0) return; + const _activeTabIndex = getActiveTabIndex(); + if (_activeTabIndex !== activeTabIndex) setActiveTab(_activeTabIndex); + }, [currentLane, currentVersion, snaps?.length, tags?.length, tabs.length]); + + const activeTabOrSnap: 'SNAP' | 'TAG' | 'LANE' | undefined = React.useMemo( + () => (activeTabIndex !== undefined ? tabs[activeTabIndex]?.name : undefined), + [activeTabIndex, tabs.length] + ); + + const hasMore = React.useMemo( + () => (activeTabOrSnap === 'SNAP' ? !!hasMoreSnaps : activeTabOrSnap === 'TAG' && !!hasMoreTags), + [hasMoreSnaps, activeTabOrSnap, hasMoreTags] + ); + + const [isIndicatorActive, setIsIndicatorActive] = React.useState(false); + + const handleScroll = (event) => { + if (event.target.scrollTop === 0) { + setIsIndicatorActive(true); + } else { + setIsIndicatorActive(false); + } + }; + + React.useEffect(() => { + if (!currentVersion) return; + if (currentVersionRef.current) { + currentVersionRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + } + }, [currentVersion]); + + React.useEffect(() => { + setIsIndicatorActive(false); + }, [activeTabIndex]); + + const handleLoadMore = React.useCallback( + (backwards?: boolean) => { + if (activeTabOrSnap === 'SNAP') loadMoreSnaps?.(backwards); + if (activeTabOrSnap === 'TAG') loadMoreTags?.(backwards); + }, + [activeTabOrSnap, tabs.length] + ); + const lastLogRef = React.useCallback( + (node) => { + if (loading) return; + if (lastObserver.current) lastObserver.current.disconnect(); + lastObserver.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore) { + handleLoadMore(); + } + }, + { + threshold: 0.1, + rootMargin: '100px', + } + ); + if (node) lastObserver.current.observe(node); + }, + [loading, hasMore, handleLoadMore] + ); + + const firstLogRef = React.useCallback( + (node) => { + if (loading) return; + if (firstObserver.current) firstObserver.current.disconnect(); + firstObserver.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore) { + handleLoadMore(true); + } + }, + { + threshold: 1, + rootMargin: '0px', + } + ); + if (node) firstObserver.current.observe(node); + }, + [loading, hasMore, handleLoadMore] + ); + + const versionRef = (node, version, index, versions) => { + if (index === 0) { + firstLogRef(node); + return { current: firstObserver.current }; + } + if (index === versions.length - 1) { + lastLogRef(node); + return { current: lastObserver.current }; + } + if (version === currentVersion) return currentVersionRef; + return null; + }; const multipleTabs = tabs.length > 1; const message = multipleTabs ? 'Switch to view tags, snaps, or lanes' - : `Switch between ${tabs[0].name.toLocaleLowerCase()}s`; + : `Switch between ${tabs[0]?.name.toLocaleLowerCase()}s`; + + const showTab = activeTabIndex !== undefined && tabs[activeTabIndex]?.payload.length > 0; return (
-
{message}
- {localVersion && ( + {loading && } + {!loading &&
{message}
} + {!loading && localVersion && ( -
- {tabs[activeTabIndex].name === 'LANE' && - tabs[activeTabIndex].payload.map((payload) => ( +
+ {showTab && tabs[activeTabIndex]?.name !== 'LANE' && ( +
+ versionRef(node, (tabs[activeTabIndex]?.payload?.[0] as any).version, 0, tabs[activeTabIndex]?.payload) + } + > + Pull down to load more +
+ )} + {showTab && + tabs[activeTabIndex]?.name === 'LANE' && + tabs[activeTabIndex]?.payload.map((payload) => ( ))} - {tabs[activeTabIndex].name !== 'LANE' && - tabs[activeTabIndex].payload.map((payload) => ( - - ))} + {showTab && + tabs[activeTabIndex]?.name !== 'LANE' && + tabs[activeTabIndex]?.payload.map((payload, index, versions) => { + // const _ref = (index === tabs[activeTabIndex]?.payload.length - 1 && lastLogRef) || ref; + return ( + + versionRef(node, index === 0 ? undefined : payload.version, index === 0 ? undefined : index, versions) + } + key={payload.version} + currentVersion={currentVersion} + latestVersion={latestVersion} + overrideVersionHref={overrideVersionHref} + showDetails={showVersionDetails} + {...payload} + > + ); + })}
); } + +export type UseComponentVersionsResult = { + tags?: DropdownComponentVersion[]; + snaps?: DropdownComponentVersion[]; + loadMoreTags?: (backwards?: boolean) => void; + loadMoreSnaps?: (backwards?: boolean) => void; + hasMoreTags?: boolean; + hasMoreSnaps?: boolean; + loading?: boolean; +}; + +export type UseComponentVersions = () => UseComponentVersionsResult; + +export type VersionDropdownProps = { + localVersion?: boolean; + latestVersion?: string; + currentVersion: string; + currentVersionLog?: { + timestamp?: string | number; + author?: { + displayName?: string; + email?: string; + }; + message?: string; + }; + hasMoreVersions?: boolean; + loading?: boolean; + useComponentVersions?: UseComponentVersions; + currentLane?: LaneModel; + lanes?: LaneModel[]; + overrideVersionHref?: (version: string) => string; + placeholderClassName?: string; + dropdownClassName?: string; + menuClassName?: string; + showVersionDetails?: boolean; + disabled?: boolean; + placeholderComponent?: ReactNode; +} & React.HTMLAttributes; + +export const VersionDropdown = React.memo(_VersionDropdown); + +function _VersionDropdown({ + currentVersion, + latestVersion, + localVersion, + currentVersionLog = {}, + hasMoreVersions, + loading, + overrideVersionHref, + className, + placeholderClassName, + dropdownClassName, + menuClassName, + showVersionDetails, + disabled, + placeholderComponent, + currentLane, + useComponentVersions, + lanes, + ...rest +}: VersionDropdownProps) { + const [key, setKey] = useState(0); + const singleVersion = !hasMoreVersions; + const [open, setOpen] = useState(false); + + React.useEffect(() => { + if (loading && open) { + setOpen(false); + } + }, [loading]); + + const { author, message, timestamp } = currentVersionLog; + const handlePlaceholderClicked = (e: React.MouseEvent) => { + if (loading) return; + if (e.target === e.currentTarget) { + setOpen((o) => !o); + } + }; + + const defaultPlaceholder = ( + + ); + + const PlaceholderComponent = placeholderComponent || defaultPlaceholder; + + if (disabled || (singleVersion && !loading)) { + return
{PlaceholderComponent}
; + } + + return ( +
+ setOpen(false)} + onChange={(_e, _open) => _open && setKey((x) => x + 1)} // to reset menu to initial state when toggling + PlaceholderComponent={({ children, ...other }) => ( +
+ {children} +
+ )} + placeholder={PlaceholderComponent} + > + {open && ( + + )} +
+
+ ); +} + +type VersionMenuProps = { + localVersion?: boolean; + currentVersion?: string; + latestVersion?: string; + useVersions?: UseComponentVersions; + currentLane?: LaneModel; + lanes?: LaneModel[]; + overrideVersionHref?: (version: string) => string; + showVersionDetails?: boolean; + loading?: boolean; +} & React.HTMLAttributes; diff --git a/scopes/component/ui/version-dropdown/version-info/version-info.tsx b/scopes/component/ui/version-dropdown/version-info/version-info.tsx index 85bff8c4a549..10f340bc39ad 100644 --- a/scopes/component/ui/version-dropdown/version-info/version-info.tsx +++ b/scopes/component/ui/version-dropdown/version-info/version-info.tsx @@ -16,18 +16,22 @@ export type VersionInfoProps = DropdownComponentVersion & { showDetails?: boolean; }; -export function VersionInfo({ - version, - currentVersion, - latestVersion, - date, - username, - email, - overrideVersionHref, - showDetails, - message, - tag, -}: VersionInfoProps) { +export const VersionInfo = React.memo(React.forwardRef(_VersionInfo)); +function _VersionInfo( + { + version, + currentVersion, + latestVersion, + date, + username, + email, + overrideVersionHref, + showDetails, + message, + tag, + }: VersionInfoProps, + ref?: React.ForwardedRef +) { const isCurrent = version === currentVersion; const author = useMemo(() => { return { @@ -38,6 +42,7 @@ export function VersionInfo({ const timestamp = useMemo(() => (date ? new Date(parseInt(date)).toString() : new Date().toString()), [date]); const currentVersionRef = useRef(null); + useEffect(() => { if (isCurrent) { currentVersionRef.current?.scrollIntoView({ block: 'nearest' }); @@ -47,7 +52,7 @@ export function VersionInfo({ const href = overrideVersionHref ? overrideVersionHref(version) : `?version=${version}`; return ( -
+
diff --git a/scopes/lanes/hooks/use-lanes/lanes-provider.tsx b/scopes/lanes/hooks/use-lanes/lanes-provider.tsx index 5c395fc5b592..182f6a8df9f7 100644 --- a/scopes/lanes/hooks/use-lanes/lanes-provider.tsx +++ b/scopes/lanes/hooks/use-lanes/lanes-provider.tsx @@ -27,6 +27,16 @@ export function LanesProvider({ const [lanesState, setLanesState] = useState(lanesModel); const [viewedLaneId, setViewedLaneId] = useState(viewedIdFromProps); + const updateViewedLane = useCallback( + (lane?: LaneId) => { + setViewedLaneId(lane); + setLanesState((state) => { + state?.setViewedOrDefaultLane(lane); + return state; + }); + }, + [lanesModel] + ); const location = useLocation(); const query = useQuery(); @@ -37,7 +47,7 @@ export function LanesProvider({ }, []); useEffect(() => { - if (viewedIdFromProps) setViewedLaneId(viewedIdFromProps); + if (viewedIdFromProps) updateViewedLane(viewedIdFromProps); }, [viewedIdFromProps?.toString()]); useEffect(() => { @@ -50,26 +60,28 @@ export function LanesProvider({ const viewedLaneIdToSet = viewedLaneIdFromUrl || lanesModel?.currentLane?.id || lanesModel?.lanes.find((lane) => lane.id.isDefault())?.id; - setViewedLaneId(viewedLaneIdToSet); + updateViewedLane(viewedLaneIdToSet); }, [location?.pathname]); useEffect(() => { - if (viewedLaneId === undefined && lanesModel?.currentLane?.id) { - setViewedLaneId(lanesModel.currentLane.id); - lanesModel?.setViewedOrDefaultLane(lanesModel.currentLane.id); - setLanesState(lanesModel); - return; - } - lanesModel?.setViewedOrDefaultLane(viewedLaneId); - setLanesState(lanesModel); - }, [loading, lanesModel?.lanes.length]); - - lanesState?.setViewedOrDefaultLane(viewedLaneId); + setLanesState((existing) => { + if (!loading && lanesModel?.lanes.length) { + const state = new LanesModel({ ...lanesModel }); + if (viewedLaneId === undefined && lanesModel?.currentLane?.id) { + state.setViewedOrDefaultLane(lanesModel?.currentLane?.id); + } else { + state.setViewedOrDefaultLane(viewedLaneId); + } + return state; + } + return existing; + }); + }, [loading, lanesModel?.lanes.length, viewedLaneId]); const lanesContextModel: LanesContextModel = { lanesModel: lanesState, updateLanesModel: setLanesState, - updateViewedLane: setViewedLaneId, + updateViewedLane, }; return {children}; diff --git a/scopes/lanes/hooks/use-lanes/use-lanes.tsx b/scopes/lanes/hooks/use-lanes/use-lanes.tsx index 4f30976048eb..4a4b2d6348c3 100644 --- a/scopes/lanes/hooks/use-lanes/use-lanes.tsx +++ b/scopes/lanes/hooks/use-lanes/use-lanes.tsx @@ -50,7 +50,7 @@ export function useLanes( skip?: boolean ): LanesContextModel & Omit, 'data'> { const lanesContext = useLanesContext(); - const shouldSkip = skip || !!targetLanes || !!lanesContext; + const shouldSkip = skip || !!targetLanes || !!lanesContext?.lanesModel; const { data, loading, ...rest } = useDataQuery(GET_LANES, { skip: shouldSkip, diff --git a/scopes/lanes/lanes/lanes.ui.runtime.tsx b/scopes/lanes/lanes/lanes.ui.runtime.tsx index 95e55d1c79a5..8fa270298fdb 100644 --- a/scopes/lanes/lanes/lanes.ui.runtime.tsx +++ b/scopes/lanes/lanes/lanes.ui.runtime.tsx @@ -16,6 +16,7 @@ import { LanesOrderedNavigationSlot, LanesOverviewMenu, } from '@teambit/lanes.ui.menus.lanes-overview-menu'; +import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query'; import { UseLaneMenu } from '@teambit/lanes.ui.menus.use-lanes-menu'; import { LanesHost, LanesModel } from '@teambit/lanes.ui.models.lanes-model'; import { LanesProvider, useLanes, IgnoreDerivingFromUrl } from '@teambit/lanes.hooks.use-lanes'; @@ -30,6 +31,50 @@ import styles from './lanes.ui.module.scss'; export type LaneCompareProps = Partial; export type LaneProviderIgnoreSlot = SlotRegistry; +export function useComponentFilters() { + const idFromLocation = useIdFromLocation(); + const { lanesModel, loading } = useLanes(); + const laneFromUrl = useViewedLaneFromUrl(); + const laneComponentId = + idFromLocation && !laneFromUrl?.isDefault() + ? lanesModel?.resolveComponentFromUrl(idFromLocation, laneFromUrl) ?? null + : null; + + if (laneComponentId === null || loading) { + return { + loading: true, + }; + } + + return { + loading: false, + log: { + logHead: laneComponentId.version, + }, + }; +} +export function useLaneComponentIdFromUrl(): ComponentID | undefined | null { + const idFromLocation = useIdFromLocation(); + const { lanesModel, loading } = useLanes(); + const laneFromUrl = useViewedLaneFromUrl(); + const query = useQuery(); + const componentVersion = query.get('version'); + + if (componentVersion && laneFromUrl) { + const componentId = ComponentID.fromString(`${idFromLocation}@${componentVersion}`); + return componentId; + } + const laneComponentId = + idFromLocation && !laneFromUrl?.isDefault() + ? lanesModel?.resolveComponentFromUrl(idFromLocation, laneFromUrl) ?? null + : null; + + return loading ? undefined : laneComponentId; +} + +export function useComponentId() { + return useLaneComponentIdFromUrl()?.toString(); +} export class LanesUI { static dependencies = [UIAspect, ComponentAspect, WorkspaceAspect, ScopeAspect, ComponentCompareAspect]; @@ -112,42 +157,17 @@ export class LanesUI { // return ; // } - getLaneComponentIdFromUrl = () => { - const idFromLocation = useIdFromLocation(); - const { lanesModel } = useLanes(); - const laneFromUrl = useViewedLaneFromUrl(); - const laneComponentId = - idFromLocation && !laneFromUrl?.isDefault() - ? lanesModel?.resolveComponentFromUrl(idFromLocation, laneFromUrl) - : undefined; - return laneComponentId; - }; - - useComponentId = () => { - return this.getLaneComponentIdFromUrl()?.toString(); - }; - - useComponentFilters = () => { - const laneComponentId = this.getLaneComponentIdFromUrl(); - - return { - log: laneComponentId && { - logHead: laneComponentId.version, - }, - }; - }; - getLaneComponent() { return this.componentUI.getComponentUI(this.host, { - componentId: this.useComponentId, - useComponentFilters: this.useComponentFilters, + componentId: useComponentId, + useComponentFilters, }); } getLaneComponentMenu() { return this.componentUI.getMenu(this.host, { - componentId: this.useComponentId, - useComponentFilters: this.useComponentFilters, + componentId: useComponentId, + useComponentFilters, }); } @@ -252,7 +272,7 @@ export class LanesUI { ); diff --git a/scopes/scope/scope/scope.main.runtime.ts b/scopes/scope/scope/scope.main.runtime.ts index 0d8b3bc19144..cda63331ccf3 100644 --- a/scopes/scope/scope/scope.main.runtime.ts +++ b/scopes/scope/scope/scope.main.runtime.ts @@ -27,7 +27,7 @@ import { UIAspect } from '@teambit/ui'; import { BitId } from '@teambit/legacy-bit-id'; import { BitIds, BitIds as ComponentsIds } from '@teambit/legacy/dist/bit-id'; import { ModelComponent, Lane } from '@teambit/legacy/dist/scope/models'; -import { Repository } from '@teambit/legacy/dist/scope/objects'; +import { Ref, Repository } from '@teambit/legacy/dist/scope/objects'; import LegacyScope, { LegacyOnTagResult } from '@teambit/legacy/dist/scope/scope'; import { ComponentLog } from '@teambit/legacy/dist/scope/models/model-component'; import { loadScopeIfExist } from '@teambit/legacy/dist/scope/scope-loader'; @@ -680,12 +680,173 @@ export class ScopeMain implements ComponentFactory { if (!ref) throw new Error(`ref was not found: ${id.toString()} with tag ${hash}`); return this.componentLoader.getSnap(id, ref.toString()); } + /** + * Performs a Depth-First Search (DFS) to find a node in a graph by offset. + * + * This function starts from a given node and moves either forward or backward in the graph, depending on the provided getNodesFunc function. + * The goal is to reach a node that is at a given offset from the starting node + * + * If the graph cannot go any deeper (i.e., there are no more successors or predecessors), or the offset becomes zero, the function will return the last visited node. + * This ensures that the function always returns a node, either the exact offset node (if it exists) or the closest node by offset. + * + * @param versionGraph The graph in which to perform the search. + * @param offset The offset from the starting node to find. It is always an absolute value. + * @param getNodesFunc A function that, given a node id, returns the successors (if moving forward) or predecessors (if moving backward) of that node in the graph. + * @param node The node from where to start the search. If not provided, the function will return undefined. + * @param nodeFilter Optional. A function that, given a node, determines if it should be included in the search. If not provided, all nodes are included. + * @param edgeFilter Optional. A function that, given an edge, determines if it should be included in the search. If not provided, all edges are included. + * @param skipNode Optional. A function that, given a node, determines if it should be skipped (not decrease the offset). If not provided, no nodes are skipped. + * + * @returns The node that corresponds to the exact offset if it exists, or the closest node by offset otherwise. If no starting node is provided, the function will return undefined. + */ + private findNodeByOffset( + versionGraph: Graph, + offset: number, + getNodesFunc: ( + id: string, + { + nodeFilter, + edgeFilter, + }?: { + nodeFilter?: (node: Node) => boolean; + edgeFilter?: (edge: Edge) => boolean; + } + ) => Array>, + node?: Node, + nodeFilter?: (node: Node) => boolean, + edgeFilter?: (edge: Edge) => boolean, + skipNode?: (node: Node) => boolean + ) { + if (offset === 0 || !node) return node; + + const nextNodes = getNodesFunc(node.id, { edgeFilter, nodeFilter }); + + if (!nextNodes || nextNodes.length === 0) { + return node; + } + + for (const nextNode of nextNodes) { + const skip = skipNode && skipNode(nextNode); + const nextOffset = skip ? offset : offset - 1; + + const foundNode = this.findNodeByOffset( + versionGraph, + nextOffset, + getNodesFunc, + nextNode, + nodeFilter, + edgeFilter, + skipNode + ); + + if (foundNode) { + return foundNode; + } + } + + return node; + } /** - * get component log sorted by the timestamp in ascending order (from the earliest to the latest) + * Fetches the logs for a given component. + * + * @param id The ComponentID of the component for which to fetch logs. + * @param shortHash If true, returns a shorter version of the hash. Defaults to false. + * @param head The specific version to start fetching logs from. If not provided, starts from the head. + * @param startFrom The specific version to start slicing logs from. + * @param stopAt The specific version to stop fetching logs at. + * @param startFromOffset Offset from the start version to fetch logs from. + * @param stopAtOffset Offset from the stop version to fetch logs at. + * @param type Optional. The type of logs to fetch - 'snap' or 'tag' + * + * @returns A promise that resolves to an array of ComponentLog objects representing the filtered logs for the component. + * + * @throws Error - Throws an error if the node with given headRef hash is not found. */ - async getLogs(id: ComponentID, shortHash = false, startsFrom?: string): Promise { - return this.legacyScope.loadComponentLogs(id._legacy, shortHash, startsFrom); + + async getLogs( + id: ComponentID, + shortHash = false, + head?: string, + startFrom?: string, + stopAt?: string, + startFromOffset?: number, + stopAtOffset?: number, + type?: 'snap' | 'tag' + ): Promise { + const componentModel = await this.legacyScope.getModelComponentIfExist(id._legacy); + + if (!componentModel) return []; + + const headRef = head ? componentModel.getRef(head) : componentModel.head; + + if (!headRef || (startFromOffset === undefined && stopAtOffset === undefined)) { + return this.legacyScope.loadComponentLogs(id._legacy, shortHash, startFrom, stopAt ? [stopAt] : undefined); + } + + const versionHistory = await componentModel.getAndPopulateVersionHistory(this.legacyScope.objects, headRef); + const versionGraph = versionHistory.getGraph(); + const startFromRef = startFrom ? componentModel.getRef(startFrom) : undefined; + let startNode = versionGraph.node(startFromRef?.hash ?? headRef.hash); + + if (!startNode) { + this.logger.error(`Node with id ${headRef.hash} not found`); + return []; + } + + const startOffset = startFromOffset || 0; + + const componentVersionSet = new Set(componentModel.versionArray.map((v) => v.hash)); + + const skipNode = (node: Node) => { + if (type === 'snap') { + return componentVersionSet.has(node.id); + } + if (type === 'tag') { + return !componentVersionSet.has(node.id); + } + + return false; + }; + + if (startOffset !== 0) { + startNode = this.findNodeByOffset( + versionGraph, + Math.abs(startOffset), + startOffset > 0 ? versionGraph.successors.bind(versionGraph) : versionGraph.predecessors.bind(versionGraph), + startNode, + undefined, + (edge) => edge.attr === 'parent', + skipNode + ); + } + + const stopOffset = stopAtOffset || 0; + const stopRef = stopAt ? componentModel.getRef(stopAt) : (stopOffset !== 0 && { hash: startNode?.id }) || undefined; + let stopNode = stopRef?.hash ? versionGraph.node(stopRef.hash) : undefined; + if (stopOffset !== 0) { + stopNode = this.findNodeByOffset( + versionGraph, + Math.abs(stopOffset), + stopOffset > 0 ? versionGraph.successors.bind(versionGraph) : versionGraph.predecessors.bind(versionGraph), + stopNode, + undefined, + (edge) => edge.attr === 'parent', + skipNode + ); + } + + return this.legacyScope + .loadComponentLogs(id._legacy, shortHash, startNode?.id, stopNode ? [stopNode.id] : undefined) + .then((logs) => { + if (type === 'snap') { + return logs.filter((log) => !log.tag); + } + if (type === 'tag') { + return logs.filter((log) => log.tag); + } + return logs; + }); } async getStagedConfig() { diff --git a/scopes/workspace/workspace/workspace.ts b/scopes/workspace/workspace/workspace.ts index 861f34f98775..5f4ca3475dbb 100644 --- a/scopes/workspace/workspace/workspace.ts +++ b/scopes/workspace/workspace/workspace.ts @@ -426,8 +426,17 @@ export class Workspace implements ComponentFactory { return this.getMany(ids); } - async getLogs(id: ComponentID, shortHash = false, startsFrom?: string): Promise { - return this.scope.getLogs(id, shortHash, startsFrom); + async getLogs( + id: ComponentID, + shortHash = false, + head?: string, + startFrom?: string, + stopAt?: string, + startFromOffset?: number, + stopAtOffset?: number, + type?: 'snap' | 'tag' + ): Promise { + return this.scope.getLogs(id, shortHash, head, startFrom, stopAt, startFromOffset, stopAtOffset, type); } async getGraph(ids?: ComponentID[], shouldThrowOnMissingDep = true): Promise> { diff --git a/src/scope/models/model-component.ts b/src/scope/models/model-component.ts index 5a3e45500674..b889a4887977 100644 --- a/src/scope/models/model-component.ts +++ b/src/scope/models/model-component.ts @@ -497,9 +497,9 @@ export default class Component extends BitObject { /** * get component log and sort by the timestamp in ascending order (from the earliest to the latest) */ - async collectLogs(scope: Scope, shortHash = false, startFrom?: Ref): Promise { + async collectLogs(scope: Scope, shortHash = false, startFrom?: Ref, stopAt?: Ref[]): Promise { const repo = scope.objects; - let versionsInfo = await getAllVersionsInfo({ modelComponent: this, repo, throws: false, startFrom }); + let versionsInfo = await getAllVersionsInfo({ modelComponent: this, repo, throws: false, startFrom, stopAt }); // due to recent changes of getting version-history object rather than fetching the entire history, some version // objects might be missing. import the component from the remote diff --git a/src/scope/scope.ts b/src/scope/scope.ts index 00130c5e325c..0110294c54cd 100644 --- a/src/scope/scope.ts +++ b/src/scope/scope.ts @@ -2,6 +2,7 @@ import fs from 'fs-extra'; import * as pathLib from 'path'; import R from 'ramda'; import { LaneId } from '@teambit/lane-id'; +import { compact } from 'lodash'; import semver from 'semver'; import { isTag } from '@teambit/component-version'; import { Analytics } from '../analytics/analytics'; @@ -535,11 +536,19 @@ once done, to continue working, please run "bit cc"` return removeNils(components); } - async loadComponentLogs(id: BitId, shortHash = false, startFrom?: string): Promise { + async loadComponentLogs( + id: BitId, + shortHash = false, + startFrom?: string, + stopsAt?: string[] + ): Promise { const componentModel = await this.getModelComponentIfExist(id); if (!componentModel) return []; + const startFromRef = startFrom ? componentModel.getRef(startFrom) ?? undefined : undefined; - const logs = await componentModel.collectLogs(this, shortHash, startFromRef); + const stopsAtRef = stopsAt ? compact(stopsAt.map((s) => componentModel.getRef(s) ?? undefined)) : undefined; + + const logs = await componentModel.collectLogs(this, shortHash, startFromRef, stopsAtRef); return logs; } diff --git a/workspace.jsonc b/workspace.jsonc index 054004fa6f7a..ca5e2a29e0c1 100644 --- a/workspace.jsonc +++ b/workspace.jsonc @@ -82,7 +82,6 @@ "@teambit/bvm.config": "0.2.2", "@teambit/capsule": "0.0.12", "@teambit/code.ui.code-compare-section": "^0.0.5", - "@teambit/code.ui.code-view": "^0.0.508", "@teambit/code.ui.dependency-tree": "^0.0.546", "@teambit/code.ui.hooks.use-code-params": "^0.0.496", "@teambit/code.ui.object-formatter": "0.0.1",