diff --git a/src/commands/flinkArtifacts.test.ts b/src/commands/flinkArtifacts.test.ts index fbc1a43d0..51d1851ba 100644 --- a/src/commands/flinkArtifacts.test.ts +++ b/src/commands/flinkArtifacts.test.ts @@ -12,7 +12,7 @@ import type { PresignedUploadUrlArtifactV1PresignedUrl200ResponseKindEnum, } from "../clients/flinkArtifacts/models/PresignedUploadUrlArtifactV1PresignedUrl200Response"; import type { FlinkArtifact } from "../models/flinkArtifact"; -import { FlinkDatabaseResourceContainer } from "../models/flinkDatabaseResourceContainer"; +import { FlinkDatabaseResourceContainer } from "../models/containers/flinkDatabaseResourceContainer"; import { type EnvironmentId } from "../models/resource"; import * as notifications from "../notifications"; import { FlinkDatabaseViewProvider } from "../viewProviders/flinkDatabase"; diff --git a/src/commands/flinkDatabaseView.test.ts b/src/commands/flinkDatabaseView.test.ts index 28fc94068..6ac32683a 100644 --- a/src/commands/flinkDatabaseView.test.ts +++ b/src/commands/flinkDatabaseView.test.ts @@ -21,7 +21,7 @@ import type { CCloudResourceLoader } from "../loaders"; import { FlinkDatabaseContainerLabel, FlinkDatabaseResourceContainer, -} from "../models/flinkDatabaseResourceContainer"; +} from "../models/containers/flinkDatabaseResourceContainer"; import { FlinkDatabaseViewProvider } from "../viewProviders/flinkDatabase"; describe("commands/flinkDatabaseView.ts", () => { diff --git a/src/commands/flinkDatabaseView.ts b/src/commands/flinkDatabaseView.ts index 341ce8043..9b263ef0b 100644 --- a/src/commands/flinkDatabaseView.ts +++ b/src/commands/flinkDatabaseView.ts @@ -4,8 +4,8 @@ import { setFlinkDocumentMetadata } from "../flinkSql/statementUtils"; import { CCloudResourceLoader } from "../loaders"; import { Logger } from "../logging"; import type { CCloudEnvironment } from "../models/environment"; -import type { FlinkDatabaseResourceContainer } from "../models/flinkDatabaseResourceContainer"; -import { FlinkDatabaseContainerLabel } from "../models/flinkDatabaseResourceContainer"; +import type { FlinkDatabaseResourceContainer } from "../models/containers/flinkDatabaseResourceContainer"; +import { FlinkDatabaseContainerLabel } from "../models/containers/flinkDatabaseResourceContainer"; import { FlinkDatabaseViewProvider } from "../viewProviders/flinkDatabase"; const logger = new Logger("FlinkDatabaseViewCommands"); diff --git a/src/commands/utils/udfRegistration.test.ts b/src/commands/utils/udfRegistration.test.ts index 705f42281..3bdfb164a 100644 --- a/src/commands/utils/udfRegistration.test.ts +++ b/src/commands/utils/udfRegistration.test.ts @@ -10,9 +10,10 @@ import { createFlinkUDF } from "../../../tests/unit/testResources/flinkUDF"; import { TEST_CCLOUD_FLINK_DB_KAFKA_CLUSTER } from "../../../tests/unit/testResources/kafkaCluster"; import * as emitters from "../../emitters"; import { type CCloudResourceLoader } from "../../loaders"; -import { FlinkDatabaseResourceContainer } from "../../models/flinkDatabaseResourceContainer"; +import { FlinkDatabaseResourceContainer } from "../../models/containers/flinkDatabaseResourceContainer"; import type { FlinkUdf } from "../../models/flinkUDF"; -import { CCloudFlinkDbKafkaCluster, CCloudKafkaCluster } from "../../models/kafkaCluster"; +import type { CCloudFlinkDbKafkaCluster } from "../../models/kafkaCluster"; +import { CCloudKafkaCluster } from "../../models/kafkaCluster"; import * as notifications from "../../notifications"; import * as kafkaClusterQuickpicks from "../../quickpicks/kafkaClusters"; import * as jarInspector from "../../utils/jarInspector"; diff --git a/src/models/containers/flinkDatabaseResourceContainer.test.ts b/src/models/containers/flinkDatabaseResourceContainer.test.ts new file mode 100644 index 000000000..363461eac --- /dev/null +++ b/src/models/containers/flinkDatabaseResourceContainer.test.ts @@ -0,0 +1,33 @@ +import * as assert from "assert"; +import { createFakeFlinkDatabaseResource } from "../../../tests/unit/testResources/flinkDatabaseResource"; +import { ConnectionType } from "../../clients/sidecar"; +import { CCLOUD_CONNECTION_ID } from "../../constants"; +import type { FlinkDatabaseResource } from "../flinkDatabaseResource"; +import { FlinkDatabaseResourceContainer } from "./flinkDatabaseResourceContainer"; + +describe("models/containers/flinkDatabaseResourceContainer", () => { + describe("FlinkDatabaseResourceContainer", () => { + describe("constructor", () => { + it("should set connectionId to CCLOUD_CONNECTION_ID", () => { + const container = new FlinkDatabaseResourceContainer("Test", []); + + assert.strictEqual(container.connectionId, CCLOUD_CONNECTION_ID); + }); + + it("should set connectionType to Ccloud", () => { + const container = new FlinkDatabaseResourceContainer("Test", []); + + assert.strictEqual(container.connectionType, ConnectionType.Ccloud); + }); + + it("should set id to connectionId-label", () => { + const label = "Test Database"; + const container = new FlinkDatabaseResourceContainer(label, [ + createFakeFlinkDatabaseResource(), + ]); + + assert.strictEqual(container.id, `${CCLOUD_CONNECTION_ID}-${label}`); + }); + }); + }); +}); diff --git a/src/models/containers/flinkDatabaseResourceContainer.ts b/src/models/containers/flinkDatabaseResourceContainer.ts new file mode 100644 index 000000000..1bed879dc --- /dev/null +++ b/src/models/containers/flinkDatabaseResourceContainer.ts @@ -0,0 +1,29 @@ +import type { ThemeIcon } from "vscode"; +import { ConnectionType } from "../../clients/sidecar"; +import { CCLOUD_CONNECTION_ID } from "../../constants"; +import type { FlinkArtifact } from "../flinkArtifact"; +import type { FlinkDatabaseResource } from "../flinkDatabaseResource"; +import { ResourceContainer } from "./resourceContainer"; + +/** Labels for the top-level containers in the Flink Database view. */ +export enum FlinkDatabaseContainerLabel { + RELATIONS = "Tables and Views", + ARTIFACTS = "Artifacts", + UDFS = "UDFs", + AI_CONNECTIONS = "Connections", + AI_TOOLS = "AI Tools", + AI_MODELS = "AI Models", + AI_AGENTS = "AI Agents", +} + +/** A container {@link TreeItem} for resources to display in the Flink Database view. */ +export class FlinkDatabaseResourceContainer< + T extends FlinkDatabaseResource | FlinkArtifact, +> extends ResourceContainer { + protected readonly loggerNamePrefix = "FlinkDatabaseResourceContainer"; + + constructor(label: string, children: T[], contextValue?: string, icon?: ThemeIcon) { + // Flink Database resources are always for the CCLOUD connection + super(CCLOUD_CONNECTION_ID, ConnectionType.Ccloud, label, children, contextValue, icon); + } +} diff --git a/src/models/containers/kafkaClusterResourceContainer.test.ts b/src/models/containers/kafkaClusterResourceContainer.test.ts new file mode 100644 index 000000000..63160a453 --- /dev/null +++ b/src/models/containers/kafkaClusterResourceContainer.test.ts @@ -0,0 +1,144 @@ +import * as assert from "assert"; +import type { ThemeIcon } from "vscode"; +import { TEST_DIRECT_CONNECTION_ID } from "../../../tests/unit/testResources/connection"; +import { TEST_CCLOUD_CONSUMER_GROUP } from "../../../tests/unit/testResources/consumerGroup"; +import { ConnectionType } from "../../clients/sidecar"; +import { CCLOUD_CONNECTION_ID } from "../../constants"; +import { IconNames } from "../../icons"; +import { KafkaClusterResourceContainer } from "./kafkaClusterResourceContainer"; + +const TEST_LABEL = "Test"; +const TEST_CONTEXT_VALUE = "test-container"; + +describe("models/containers/kafkaClusterResourceContainer", () => { + describe("KafkaClusterResourceContainer", () => { + describe("constructor", () => { + it("should set connectionId from constructor argument", () => { + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + TEST_LABEL, + ); + + assert.strictEqual(container.connectionId, CCLOUD_CONNECTION_ID); + }); + + it("should set connectionType from constructor argument", () => { + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + TEST_LABEL, + ); + + assert.strictEqual(container.connectionType, ConnectionType.Ccloud); + }); + + it("should set the label from constructor argument", () => { + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + TEST_LABEL, + ); + + assert.strictEqual(container.label, TEST_LABEL); + }); + + it("should set contextValue when provided", () => { + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + TEST_LABEL, + [], + TEST_CONTEXT_VALUE, + ); + + assert.strictEqual(container.contextValue, TEST_CONTEXT_VALUE); + }); + + it("should set icon when provided", () => { + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + TEST_LABEL, + [], + undefined, + { id: IconNames.CONSUMER_GROUP } as ThemeIcon, + ); + + assert.strictEqual((container.iconPath as ThemeIcon).id, IconNames.CONSUMER_GROUP); + }); + + it("should default to empty children", () => { + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + TEST_LABEL, + ); + + assert.deepStrictEqual(container.children, []); + }); + + it("should accept initial children", () => { + const children = [TEST_CCLOUD_CONSUMER_GROUP]; + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + TEST_LABEL, + children, + ); + + assert.deepStrictEqual(container.children, children); + }); + }); + + describe("id derivation", () => { + it("should derive id from connectionId and label", () => { + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + "Topics", + ); + + assert.strictEqual(container.id, `${CCLOUD_CONNECTION_ID}-Topics`); + }); + + it("should preserve multi-word labels in id", () => { + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + "Consumer Groups", + ); + + assert.strictEqual(container.id, `${CCLOUD_CONNECTION_ID}-Consumer Groups`); + }); + + it("should use different ids for different connection types", () => { + const ccloudContainer = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + TEST_LABEL, + ); + const directContainer = new KafkaClusterResourceContainer( + TEST_DIRECT_CONNECTION_ID, + ConnectionType.Direct, + TEST_LABEL, + ); + + assert.notStrictEqual(ccloudContainer.id, directContainer.id); + }); + }); + + describe("loggerName", () => { + it("should include label in loggerName", () => { + const label = "Test Resources"; + const container = new KafkaClusterResourceContainer( + CCLOUD_CONNECTION_ID, + ConnectionType.Ccloud, + label, + ); + + assert.strictEqual(container.loggerName, `KafkaClusterResourceContainer.${label}`); + }); + }); + }); +}); diff --git a/src/models/containers/kafkaClusterResourceContainer.ts b/src/models/containers/kafkaClusterResourceContainer.ts new file mode 100644 index 000000000..406f63018 --- /dev/null +++ b/src/models/containers/kafkaClusterResourceContainer.ts @@ -0,0 +1,13 @@ +import type { ISearchable } from "../resource"; +import { ResourceContainer } from "./resourceContainer"; + +/** Labels for the top-level containers in the Topics view. */ +export enum KafkaClusterContainerLabel { + TOPICS = "Topics", + CONSUMER_GROUPS = "Consumer Groups", +} + +/** A container {@link TreeItem} for resources to display in the Topics view. */ +export class KafkaClusterResourceContainer extends ResourceContainer { + protected readonly loggerNamePrefix = "KafkaClusterResourceContainer"; +} diff --git a/src/models/containers/resourceContainer.test.ts b/src/models/containers/resourceContainer.test.ts new file mode 100644 index 000000000..48de7c14b --- /dev/null +++ b/src/models/containers/resourceContainer.test.ts @@ -0,0 +1,415 @@ +import * as assert from "assert"; +import * as sinon from "sinon"; +import { MarkdownString, ThemeIcon, TreeItemCollapsibleState } from "vscode"; +import { createTestResource } from "../../../tests/unit/testResources/base"; +import { ConnectionType } from "../../clients/sidecar"; +import { CCLOUD_CONNECTION_ID } from "../../constants"; +import { ERROR_ICON, IconNames } from "../../icons"; +import type { BaseViewProviderData } from "../../viewProviders/baseModels/base"; +import { LOADING_POLL_INTERVAL_MS, ResourceContainer } from "./resourceContainer"; + +/** Minimal concrete subclass to test abstract base. */ +class TestContainer extends ResourceContainer { + protected readonly loggerNamePrefix = "TestContainer"; + + constructor( + label: string, + children: BaseViewProviderData[], + contextValue?: string, + icon?: ThemeIcon, + ) { + super(CCLOUD_CONNECTION_ID, ConnectionType.Ccloud, label, children, contextValue, icon); + } +} + +describe("models/containers/resourceContainer.ts", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("ResourceContainer", () => { + const testLabel = "Test Resource Group"; + const testContextValue = "test.context"; + const testResource = createTestResource("test-resource"); + const testResources = [ + createTestResource("test-resource-1"), + createTestResource("test-resource-2"), + ]; + + describe("constructor", () => { + it("should set label and id from arguments", () => { + const container = new TestContainer(testLabel, testResources); + + assert.strictEqual(container.label, testLabel); + assert.strictEqual(container.id, `${CCLOUD_CONNECTION_ID}-${testLabel}`); + }); + + it("should always set the collapsible state to Collapsed", () => { + const withChildren = new TestContainer(testLabel, testResources); + assert.strictEqual(withChildren.collapsibleState, TreeItemCollapsibleState.Collapsed); + + const withoutChildren = new TestContainer(testLabel, []); + assert.strictEqual(withoutChildren.collapsibleState, TreeItemCollapsibleState.Collapsed); + }); + + it("should set iconPath from the icon argument", () => { + const icon = new ThemeIcon("symbol-folder"); + const container = new TestContainer(testLabel, [], undefined, icon); + + assert.strictEqual(container.iconPath, icon); + }); + + it("should leave iconPath undefined when no icon is provided", () => { + const container = new TestContainer(testLabel, []); + + assert.strictEqual(container.iconPath, undefined); + }); + + it("should set contextValue when provided", () => { + const container = new TestContainer(testLabel, [], testContextValue); + + assert.strictEqual(container.contextValue, testContextValue); + }); + + it("should leave contextValue undefined when omitted", () => { + const container = new TestContainer(testLabel, []); + + assert.strictEqual(container.contextValue, undefined); + }); + + it("should leave description undefined before any state transition", () => { + const container = new TestContainer(testLabel, []); + + assert.strictEqual(container.description, undefined); + }); + + it("should start with isLoading=false and hasError=false", () => { + const container = new TestContainer(testLabel, []); + + assert.strictEqual(container.isLoading, false); + assert.strictEqual(container.hasError, false); + }); + }); + + describe("searchableText", () => { + it("should return the label as searchable text", () => { + const container = new TestContainer(testLabel, []); + + assert.strictEqual(container.searchableText(), testLabel); + }); + }); + + describe("setLoading()", () => { + it("should set isLoading to true and hasError to false", () => { + const container = new TestContainer(testLabel, []); + + container.setLoading(); + + assert.strictEqual(container.isLoading, true); + assert.strictEqual(container.hasError, false); + }); + + it("should use the loading icon", () => { + const container = new TestContainer(testLabel, []); + + container.setLoading(); + + assert.ok(container.iconPath); + assert.strictEqual((container.iconPath as ThemeIcon).id, IconNames.LOADING); + }); + + it("should clear a previous error state", () => { + const container = new TestContainer(testLabel, [], testContextValue); + container.setError("some error"); + + container.setLoading(); + + assert.strictEqual(container.hasError, false); + assert.strictEqual((container.iconPath as ThemeIcon).id, IconNames.LOADING); + }); + }); + + describe("setLoaded()", () => { + it("should set children and update description", () => { + const container = new TestContainer(testLabel, []); + + container.setLoaded(testResources); + + assert.deepStrictEqual(container.children, testResources); + assert.strictEqual(container.description, `(${testResources.length})`); + }); + + it("should update description for empty children", () => { + const container = new TestContainer(testLabel, []); + + container.setLoaded([]); + + assert.strictEqual(container.description, "(0)"); + }); + + it("should clear isLoading and hasError", () => { + const container = new TestContainer(testLabel, []); + container.setLoading(); + + container.setLoaded([testResource]); + + assert.strictEqual(container.isLoading, false); + assert.strictEqual(container.hasError, false); + }); + + it("should restore the default icon", () => { + const icon = new ThemeIcon("symbol-folder"); + const container = new TestContainer(testLabel, [], undefined, icon); + container.setLoading(); + + container.setLoaded([testResource]); + + assert.strictEqual(container.iconPath, icon); + }); + + it("should clear iconPath when no default icon is provided", () => { + const container = new TestContainer(testLabel, []); + container.setLoading(); + + container.setLoaded([testResource]); + + assert.strictEqual(container.iconPath, undefined); + }); + + it("should clear tooltip", () => { + const container = new TestContainer(testLabel, []); + container.setError("some error"); + + container.setLoaded([testResource]); + + assert.strictEqual(container.tooltip, undefined); + }); + + it("should restore contextValue to default (clearing -error suffix)", () => { + const container = new TestContainer(testLabel, [], testContextValue); + container.setError("some error"); + assert.strictEqual(container.contextValue, `${testContextValue}-error`); + + container.setLoaded([testResource]); + + assert.strictEqual(container.contextValue, testContextValue); + }); + + it("should not modify contextValue when none was provided", () => { + const container = new TestContainer(testLabel, []); + + container.setLoaded([testResource]); + + assert.strictEqual(container.contextValue, undefined); + }); + }); + + describe("setError()", () => { + it("should set hasError to true and clear isLoading", () => { + const container = new TestContainer(testLabel, []); + container.setLoading(); + + container.setError("something went wrong"); + + assert.strictEqual(container.hasError, true); + assert.strictEqual(container.isLoading, false); + }); + + it("should clear children and set description to (0)", () => { + const container = new TestContainer(testLabel, []); + container.setLoaded(testResources); + + container.setError("something went wrong"); + + assert.deepStrictEqual(container.children, []); + assert.strictEqual(container.description, "(0)"); + }); + + it("should use the error icon", () => { + const container = new TestContainer(testLabel, []); + + container.setError("something went wrong"); + + assert.deepStrictEqual(container.iconPath, ERROR_ICON); + }); + + it("should set the tooltip from a string", () => { + const container = new TestContainer(testLabel, []); + + container.setError("something went wrong"); + + assert.strictEqual(container.tooltip, "something went wrong"); + }); + + it("should set the tooltip from a MarkdownString", () => { + const container = new TestContainer(testLabel, []); + const markdown = new MarkdownString("**Error**: something went wrong"); + + container.setError(markdown); + + assert.strictEqual(container.tooltip, markdown); + }); + + it("should append -error suffix to contextValue", () => { + const container = new TestContainer(testLabel, [], testContextValue); + + container.setError("something went wrong"); + + assert.strictEqual(container.contextValue, `${testContextValue}-error`); + }); + + it("should not modify contextValue when none was provided", () => { + const container = new TestContainer(testLabel, []); + + container.setError("something went wrong"); + + assert.strictEqual(container.contextValue, undefined); + }); + }); + + describe("state transitions", () => { + it("loading -> loaded: should settle all state correctly", () => { + const container = new TestContainer(testLabel, [], testContextValue); + container.setLoading(); + + container.setLoaded([testResource]); + + assert.strictEqual(container.isLoading, false); + assert.strictEqual(container.hasError, false); + assert.strictEqual(container.description, "(1)"); + assert.strictEqual(container.iconPath, undefined); + assert.strictEqual(container.tooltip, undefined); + assert.strictEqual(container.contextValue, testContextValue); + }); + + it("loading -> error: should settle all state correctly", () => { + const container = new TestContainer(testLabel, [], testContextValue); + container.setLoading(); + + container.setError("failure"); + + assert.strictEqual(container.isLoading, false); + assert.strictEqual(container.hasError, true); + assert.deepStrictEqual(container.children, []); + assert.strictEqual(container.description, "(0)"); + assert.deepStrictEqual(container.iconPath, ERROR_ICON); + assert.strictEqual(container.tooltip, "failure"); + assert.strictEqual(container.contextValue, `${testContextValue}-error`); + }); + + it("error -> loading -> loaded: full recovery cycle", () => { + const container = new TestContainer(testLabel, [], testContextValue); + + container.setError("initial failure"); + assert.strictEqual(container.hasError, true); + + container.setLoading(); + assert.strictEqual(container.isLoading, true); + assert.strictEqual(container.hasError, false); + + container.setLoaded([testResource]); + assert.strictEqual(container.isLoading, false); + assert.strictEqual(container.hasError, false); + assert.strictEqual(container.contextValue, testContextValue); + assert.deepStrictEqual(container.children, [testResource]); + }); + + it("loaded -> loading -> error -> loading -> loaded: repeated transitions", () => { + const icon = new ThemeIcon("symbol-folder"); + const container = new TestContainer(testLabel, [], testContextValue, icon); + + container.setLoaded(testResources); + assert.strictEqual(container.iconPath, icon); + + container.setLoading(); + assert.strictEqual((container.iconPath as ThemeIcon).id, IconNames.LOADING); + + container.setError("transient failure"); + assert.deepStrictEqual(container.iconPath, ERROR_ICON); + assert.strictEqual(container.contextValue, `${testContextValue}-error`); + + container.setLoading(); + assert.strictEqual((container.iconPath as ThemeIcon).id, IconNames.LOADING); + + container.setLoaded([testResource]); + assert.strictEqual(container.iconPath, icon); + assert.strictEqual(container.contextValue, testContextValue); + assert.strictEqual(container.tooltip, undefined); + }); + }); + + describe("ensureDoneLoading", () => { + let clock: sinon.SinonFakeTimers; + let container: TestContainer; + + beforeEach(() => { + clock = sandbox.useFakeTimers(); + container = new TestContainer(testLabel, []); + }); + + it("should resolve immediately when not loading", async () => { + await container.ensureDoneLoading(); + + assert.strictEqual(container.isLoading, false); + }); + + it("should wait for loading to complete", async () => { + container.setLoading(); + + const waitPromise = container.ensureDoneLoading(); + + await clock.tickAsync(200); + container.setLoaded([]); + await clock.tickAsync(LOADING_POLL_INTERVAL_MS + 1); + + await waitPromise; + assert.strictEqual(container.isLoading, false); + }); + + it("should timeout if loading never completes", async () => { + container.setLoading(); + + const timeoutMs = 500; + const waitPromise = container.ensureDoneLoading(timeoutMs); + await clock.tickAsync(timeoutMs + 10); + + await assert.rejects(waitPromise, /Timeout waiting for container to finish loading/); + }); + }); + + describe("gatherResources", () => { + let ensureDoneLoadingStub: sinon.SinonStub; + let container: TestContainer; + + beforeEach(() => { + container = new TestContainer(testLabel, []); + ensureDoneLoadingStub = sandbox.stub(container, "ensureDoneLoading"); + }); + + it("should return children after calling ensureDoneLoading", async () => { + const resources = [testResource]; + container.setLoaded(resources); + + const result = await container.gatherResources(); + + assert.deepStrictEqual(result, resources); + sinon.assert.calledOnce(ensureDoneLoadingStub); + }); + + it("should return an empty array if ensureDoneLoading times out", async () => { + ensureDoneLoadingStub.rejects(new Error("Timeout waiting for container to finish loading")); + + const result = await container.gatherResources(); + + assert.deepStrictEqual(result, []); + sinon.assert.calledOnce(ensureDoneLoadingStub); + }); + }); + }); +}); diff --git a/src/models/containers/resourceContainer.ts b/src/models/containers/resourceContainer.ts new file mode 100644 index 000000000..adfd8aa52 --- /dev/null +++ b/src/models/containers/resourceContainer.ts @@ -0,0 +1,147 @@ +import { type MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode"; +import type { ConnectionType } from "../../clients/sidecar"; +import { ERROR_ICON, IconNames } from "../../icons"; +import { Logger } from "../../logging"; +import type { ConnectionId, ISearchable } from "../resource"; + +/** Poll interval to use when waiting for a container to finish loading. */ +export const LOADING_POLL_INTERVAL_MS = 100; + +/** + * Abstract base class for container {@link TreeItem tree items} that manage an array of resources + * with shared loading, error, and children state. + */ +export abstract class ResourceContainer + extends TreeItem + implements ISearchable +{ + // narrow TreeItem.id from `string | undefined` to satisfy IdItem (required by BaseViewProviderData) + declare id: string; + + // IResourceBase fields required by BaseViewProviderData + readonly connectionId: ConnectionId; + readonly connectionType: ConnectionType; + + protected abstract readonly loggerNamePrefix: string; + + private _children: T[]; + + private _isLoading: boolean = false; + private _hasError: boolean = false; + protected readonly _defaultContextValue: string | undefined; + protected readonly _defaultIcon: ThemeIcon | undefined; + + constructor( + connectionId: ConnectionId, + connectionType: ConnectionType, + label: string, + children: T[] = [], + contextValue?: string, + icon?: ThemeIcon, + ) { + super(label, TreeItemCollapsibleState.Collapsed); + + this.id = `${connectionId}-${label}`; + this.connectionId = connectionId; + this.connectionType = connectionType; + this._children = children; + + this._defaultContextValue = contextValue; + if (contextValue) { + this.contextValue = contextValue; + } + this._defaultIcon = icon; + this.iconPath = this._defaultIcon; + } + + /** Logger name combining the subclass-provided {@link loggerNamePrefix} and instance label. */ + get loggerName(): string { + return `${this.loggerNamePrefix}.${this.label}`; + } + + // lazy to avoid allocating a Logger on every container construction + private _logger?: Logger; + private get logger(): Logger { + if (!this._logger) { + this._logger = new Logger(this.loggerName); + } + return this._logger; + } + + /** Child resources belonging to this container. */ + get children(): T[] { + return this._children; + } + + get isLoading(): boolean { + return this._isLoading; + } + + get hasError(): boolean { + return this._hasError; + } + + /** Transition to loading state. Shows loading spinner icon. */ + setLoading(): void { + this._isLoading = true; + this._hasError = false; + this.iconPath = new ThemeIcon(IconNames.LOADING); + } + + /** Transition to loaded state with results. Clears loading, error, and tooltip. */ + setLoaded(children: T[]): void { + this._children = children; + this._isLoading = false; + this._hasError = false; + this.description = `(${children.length})`; + this.iconPath = this._defaultIcon; + this.tooltip = undefined; + if (this._defaultContextValue) { + this.contextValue = this._defaultContextValue; + } + } + + /** Transition to error state. Sets error icon, clears children, sets error tooltip. */ + setError(tooltip: string | MarkdownString): void { + this._children = []; + this._isLoading = false; + this._hasError = true; + this.description = "(0)"; + this.iconPath = ERROR_ICON; + this.tooltip = tooltip; + if (this._defaultContextValue) { + this.contextValue = `${this._defaultContextValue}-error`; + } + } + + searchableText(): string { + // label is required to be a string in the constructor, so we don't support the TreeItem + // label being undefined or a TreeItemLabel object here + return this.label as string; + } + + /** Wait until the container is no longer in a loading state, or timeout after `timeoutMs`. */ + async ensureDoneLoading(timeoutMs: number = 10000): Promise { + const startTime = Date.now(); + + while (this.isLoading) { + if (Date.now() - startTime >= timeoutMs) { + throw new Error("Timeout waiting for container to finish loading"); + } + await new Promise((resolve) => setTimeout(resolve, LOADING_POLL_INTERVAL_MS)); + } + } + + /** Get the container's {@link children resources}, waiting for loading to complete if necessary. */ + async gatherResources(timeoutMs: number = 10000): Promise { + let resources: T[] = []; + try { + await this.ensureDoneLoading(timeoutMs); + resources = this.children; + } catch (error) { + // should only be a timeout error: + this.logger.error(`Error getting resources: ${error}`); + } + return resources; + } +} diff --git a/src/models/flinkDatabaseResourceContainer.test.ts b/src/models/flinkDatabaseResourceContainer.test.ts deleted file mode 100644 index 6bbb450d0..000000000 --- a/src/models/flinkDatabaseResourceContainer.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -import * as assert from "assert"; -import * as sinon from "sinon"; -import { ThemeIcon, TreeItemCollapsibleState } from "vscode"; -import { createFakeFlinkDatabaseResource } from "../../tests/unit/testResources/flinkDatabaseResource"; -import { ConnectionType } from "../clients/sidecar"; -import { CCLOUD_CONNECTION_ID } from "../constants"; -import { ERROR_ICON, IconNames } from "../icons"; -import type { FlinkDatabaseResource } from "./flinkDatabaseResource"; -import { - FlinkDatabaseResourceContainer, - LOADING_POLL_INTERVAL_MS, -} from "./flinkDatabaseResourceContainer"; - -describe("models/flinkDatabaseResourceContainer", () => { - let sandbox: sinon.SinonSandbox; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe("FlinkDatabaseResourceContainer", () => { - describe("constructor", () => { - const testResources: FlinkDatabaseResource[] = [ - createFakeFlinkDatabaseResource(), - createFakeFlinkDatabaseResource({ id: "Resource2" }), - ]; - - it("should create an instance with correct properties", () => { - const label = "Test Database"; - - const item = new FlinkDatabaseResourceContainer(label, testResources); - - assert.strictEqual(item.label, label); - assert.deepStrictEqual(item.children, testResources); - assert.strictEqual(item.connectionId, CCLOUD_CONNECTION_ID); - assert.strictEqual(item.connectionType, ConnectionType.Ccloud); - assert.strictEqual(item.id, `${item.connectionId}-${label}`); - }); - - it("should always set the collapsible state to Collapsed", () => { - const label = "Test Database"; - - const withChildren = new FlinkDatabaseResourceContainer(label, testResources); - assert.strictEqual(withChildren.collapsibleState, TreeItemCollapsibleState.Collapsed); - - const withoutChildren = new FlinkDatabaseResourceContainer(label, []); - assert.strictEqual(withoutChildren.collapsibleState, TreeItemCollapsibleState.Collapsed); - }); - - it("should update description when `children` is set", () => { - const label = "Test Database"; - - const container = new FlinkDatabaseResourceContainer(label, []); - // description is not set in constructor (before loading) - assert.strictEqual(container.description, undefined); - - // but is set when children is set - container.children = testResources; - assert.strictEqual(container.description, `(${testResources.length})`); - - // and updates when children change - container.children = []; - assert.strictEqual(container.description, `(0)`); - }); - }); - - describe("searchableText", () => { - it("should return the label as searchable text", () => { - const label = "Searchable Database Resource"; - const resources: FlinkDatabaseResource[] = []; - - const item = new FlinkDatabaseResourceContainer(label, resources); - - const searchableText = item.searchableText(); - assert.strictEqual(searchableText, label); - }); - }); - - describe("isLoading", () => { - it("should start with isLoading set to false", () => { - const container = new FlinkDatabaseResourceContainer("Test", []); - - assert.strictEqual(container.isLoading, false); - }); - - it("should use the loading icon when isLoading is set to true", () => { - const container = new FlinkDatabaseResourceContainer("Test", []); - - container.isLoading = true; - assert.ok(container.iconPath); - assert.strictEqual((container.iconPath as ThemeIcon).id, IconNames.LOADING); - }); - - it("should clear .iconPath when isLoading is set to false and no default icon is provided", () => { - const container = new FlinkDatabaseResourceContainer( - "Test", - [], - // no default icon (nor contextValue) set - ); - - // set initial loading state - container.isLoading = true; - assert.ok(container.iconPath); - assert.strictEqual((container.iconPath as ThemeIcon).id, IconNames.LOADING); - - container.isLoading = false; - assert.strictEqual(container.iconPath, undefined); - }); - - it("should use the default icon when isLoading is set to false", () => { - const customIcon = new ThemeIcon("symbol-folder"); - const container = new FlinkDatabaseResourceContainer( - "Test", - [], - undefined, - customIcon, - ); - assert.strictEqual(container.iconPath, customIcon); - - // set initial loading state - container.isLoading = true; - assert.strictEqual((container.iconPath as ThemeIcon).id, IconNames.LOADING); - - container.isLoading = false; - assert.strictEqual(container.iconPath, customIcon); - }); - - it("should clear isLoading state when children are set", () => { - const container = new FlinkDatabaseResourceContainer("Test", []); - container.isLoading = true; - - const testResources = [createFakeFlinkDatabaseResource()]; - container.children = testResources; - - assert.strictEqual(container.isLoading, false); - }); - }); - - describe("hasError", () => { - it("should start with hasError set to false", () => { - const container = new FlinkDatabaseResourceContainer("Test", []); - - assert.strictEqual(container.hasError, false); - }); - - it("should use the error icon when hasError is set to true", () => { - const container = new FlinkDatabaseResourceContainer("Test", []); - - container.hasError = true; - assert.ok(container.iconPath); - assert.deepStrictEqual(container.iconPath, ERROR_ICON); - }); - - it("should clear .iconPath when hasError is set to false and no default icon is provided", () => { - const container = new FlinkDatabaseResourceContainer("Test", []); - - // set initial error state - container.hasError = true; - assert.ok(container.iconPath); - assert.deepStrictEqual(container.iconPath, ERROR_ICON); - - container.hasError = false; - assert.strictEqual(container.hasError, false); - assert.strictEqual(container.iconPath, undefined); - }); - - it("should use the default icon when hasError is set to false", () => { - const customIcon = new ThemeIcon("symbol-folder"); - const container = new FlinkDatabaseResourceContainer( - "Test", - [], - undefined, - customIcon, - ); - assert.strictEqual(container.iconPath, customIcon); - - // set initial error state - container.hasError = true; - assert.deepStrictEqual(container.iconPath, ERROR_ICON); - - container.hasError = false; - assert.strictEqual(container.iconPath, customIcon); - }); - - it("should not modify .contextValue when no original contextValue was provided in the constructor", () => { - const container = new FlinkDatabaseResourceContainer("Test", []); - - container.hasError = true; - assert.strictEqual(container.contextValue, undefined); - - container.hasError = false; - assert.strictEqual(container.contextValue, undefined); - }); - - it("should toggle the contextValue between error and non-error states without suffix duplication", () => { - const contextValue = "flinkDatabase.container"; - const container = new FlinkDatabaseResourceContainer( - "Test", - [], - contextValue, - ); - - container.hasError = true; - assert.strictEqual(container.contextValue, `${contextValue}-error`); - - container.hasError = false; - assert.strictEqual(container.contextValue, contextValue); - - container.hasError = true; - assert.strictEqual(container.contextValue, `${contextValue}-error`); - }); - }); - - describe("state interactions", () => { - it("should settle loading state and description when setting children", () => { - const container = new FlinkDatabaseResourceContainer("Test", []); - container.isLoading = true; - - const testResources = [createFakeFlinkDatabaseResource()]; - container.children = testResources; - - assert.strictEqual(container.isLoading, false); - assert.strictEqual(container.description, "(1)"); - assert.strictEqual(container.iconPath, undefined); - }); - - it("should clear hasError when setting non-empty children", () => { - const contextValue = "flinkDatabase.container"; - const container = new FlinkDatabaseResourceContainer( - "Test", - [], - contextValue, - ); - container.hasError = true; - - const testResources = [createFakeFlinkDatabaseResource()]; - container.children = testResources; - - // hasError is cleared when we have successful results - assert.strictEqual(container.hasError, false); - assert.strictEqual(container.description, "(1)"); - assert.strictEqual(container.iconPath, undefined); - assert.strictEqual(container.contextValue, contextValue); - }); - - it("should not clear hasError when setting empty children array", () => { - const contextValue = "flinkDatabase.container"; - const container = new FlinkDatabaseResourceContainer( - "Test", - [], - contextValue, - ); - container.hasError = true; - - container.children = []; - - // hasError persists when children array is empty (no successful results) - assert.strictEqual(container.hasError, true); - assert.strictEqual(container.description, "(0)"); - assert.strictEqual(container.contextValue, `${contextValue}-error`); - }); - - it("should handle multiple state transitions", () => { - const contextValue = "flinkDatabase.container"; - const container = new FlinkDatabaseResourceContainer( - "Test", - [], - contextValue, - ); - // simulate initial loading - container.isLoading = true; - assert.ok(container.iconPath); - - // set error - container.hasError = true; - assert.strictEqual(container.contextValue, `${contextValue}-error`); - assert.ok(container.iconPath); - - // set children with items (clears loading, error, and iconPath) - container.children = [createFakeFlinkDatabaseResource()]; - assert.strictEqual(container.isLoading, false); - assert.strictEqual(container.hasError, false); - assert.strictEqual(container.contextValue, contextValue); - assert.strictEqual(container.iconPath, undefined); - }); - - it("should handle error recovery with empty then non-empty children", () => { - const contextValue = "flinkDatabase.container"; - const container = new FlinkDatabaseResourceContainer( - "Test", - [], - contextValue, - ); - - // set error and empty children (error persists) - container.hasError = true; - container.children = []; - assert.strictEqual(container.hasError, true); - assert.strictEqual(container.contextValue, `${contextValue}-error`); - - // then set non-empty children (error clears) - container.children = [createFakeFlinkDatabaseResource()]; - assert.strictEqual(container.hasError, false); - assert.strictEqual(container.contextValue, contextValue); - }); - }); - - describe("ensureDoneLoading", () => { - let clock: sinon.SinonFakeTimers; - let container: FlinkDatabaseResourceContainer; - - beforeEach(() => { - clock = sandbox.useFakeTimers(); - container = new FlinkDatabaseResourceContainer("Test", []); - }); - - it("should resolve immediately when not loading", async () => { - container.isLoading = false; - - await container.ensureDoneLoading(); - - assert.strictEqual(container.isLoading, false); - }); - - it("should wait for loading to complete", async () => { - container.isLoading = true; - - const waitPromise = container.ensureDoneLoading(); - - // simulate loading completing after 200ms - await clock.tickAsync(200); - container.isLoading = false; - // one more iteration to let the polling check run - await clock.tickAsync(LOADING_POLL_INTERVAL_MS + 1); - - await waitPromise; - assert.strictEqual(container.isLoading, false); - }); - - it("should timeout if loading never completes", async () => { - container.isLoading = true; - - const timeoutMs = 500; - const waitPromise = container.ensureDoneLoading(timeoutMs); - // fast forward past the timeout - await clock.tickAsync(timeoutMs + 10); - - await assert.rejects(waitPromise, /Timeout waiting for container to finish loading/); - }); - }); - - describe("gatherResources", () => { - let ensureDoneLoadingStub: sinon.SinonStub; - let container: FlinkDatabaseResourceContainer; - - beforeEach(() => { - container = new FlinkDatabaseResourceContainer("Test", []); - // no need to handle FakeTimers here since we've tested ensureDoneLoading above - ensureDoneLoadingStub = sandbox.stub(container, "ensureDoneLoading"); - }); - - it("should return children after calling ensureDoneLoading", async () => { - const resources = [createFakeFlinkDatabaseResource()]; - container.children = resources; - - const result = await container.gatherResources(); - - assert.deepStrictEqual(result, resources); - sinon.assert.calledOnce(ensureDoneLoadingStub); - }); - - it("should return an empty array if ensureDoneLoading times out", async () => { - ensureDoneLoadingStub.rejects(new Error("Timeout waiting for container to finish loading")); - - const result = await container.gatherResources(); - - assert.deepStrictEqual(result, []); - sinon.assert.calledOnce(ensureDoneLoadingStub); - }); - }); - }); -}); diff --git a/src/models/flinkDatabaseResourceContainer.ts b/src/models/flinkDatabaseResourceContainer.ts deleted file mode 100644 index 686d55039..000000000 --- a/src/models/flinkDatabaseResourceContainer.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode"; -import { ConnectionType } from "../clients/sidecar"; -import { CCLOUD_CONNECTION_ID } from "../constants"; -import { ERROR_ICON, IconNames } from "../icons"; -import { Logger } from "../logging"; -import type { FlinkArtifact } from "./flinkArtifact"; -import type { FlinkDatabaseResource } from "./flinkDatabaseResource"; -import type { ConnectionId, ISearchable } from "./resource"; - -/** Labels for the top-level containers in the Flink Database view. */ -export enum FlinkDatabaseContainerLabel { - RELATIONS = "Tables and Views", - ARTIFACTS = "Artifacts", - UDFS = "UDFs", - AI_CONNECTIONS = "Connections", - AI_TOOLS = "AI Tools", - AI_MODELS = "AI Models", - AI_AGENTS = "AI Agents", -} - -/** Poll interval to use when waiting for a container to finish loading. */ -export const LOADING_POLL_INTERVAL_MS = 100; - -/** A container {@link TreeItem} for resources to display in the Flink Database view. */ -export class FlinkDatabaseResourceContainer - extends TreeItem - implements ISearchable -{ - readonly connectionId: ConnectionId = CCLOUD_CONNECTION_ID; - readonly connectionType: ConnectionType = ConnectionType.Ccloud; - - // `id` is string|undefined in TreeItem, but only string in IdItem so we need to specify it here - id: string; - - private _children: T[]; - - private _isLoading: boolean = false; - private _hasError: boolean = false; - private readonly _defaultContextValue: string | undefined; - private readonly _defaultIcon: ThemeIcon | undefined; - - private logger: Logger; - - constructor(label: string, children: T[], contextValue?: string, icon?: ThemeIcon) { - const collapsibleState = TreeItemCollapsibleState.Collapsed; - super(label, collapsibleState); - - this._children = children; - this.id = `${this.connectionId}-${label}`; - - this._defaultContextValue = contextValue; - if (contextValue) { - this.contextValue = contextValue; - } - this._defaultIcon = icon; - this.iconPath = this._defaultIcon; - - this.logger = new Logger(`models.FlinkDatabaseResourceContainer(${this.label})`); - } - - /** - * Flink Database resources belonging to this container. - * Setting this will clear the internal {@linkcode isLoading} state. - * If the children array has items, this will also set {@linkcode hasError} to `false`. - */ - get children(): T[] { - return this._children; - } - - set children(children: T[]) { - this._children = children; - this.isLoading = false; - this.description = `(${children.length})`; - - if (children.length > 0) { - this.hasError = false; - } - } - - get isLoading(): boolean { - return this._isLoading; - } - - set isLoading(loading: boolean) { - this._isLoading = loading; - this.iconPath = loading ? new ThemeIcon(IconNames.LOADING) : this._defaultIcon; - } - - get hasError(): boolean { - return this._hasError; - } - - /** Set or clear the error state for this container. */ - set hasError(error: boolean) { - this._hasError = error; - this.iconPath = error ? ERROR_ICON : this._defaultIcon; - - if (this._defaultContextValue) { - // append or remove "-error" suffix to context value based on error state to toggle enablement - // of resource-specific commands - this.contextValue = error ? `${this._defaultContextValue}-error` : this._defaultContextValue; - } - } - - searchableText(): string { - // label is required to be a string in the constructor, so we don't support the TreeItem - // label being undefined or a TreeItemLabel object here - return this.label as string; - } - - /** Wait until the container is no longer in a loading state, or timeout after `timeoutMs`. */ - async ensureDoneLoading(timeoutMs: number = 10000): Promise { - const startTime = Date.now(); - - while (this.isLoading) { - if (Date.now() - startTime >= timeoutMs) { - throw new Error("Timeout waiting for container to finish loading"); - } - await new Promise((resolve) => setTimeout(resolve, LOADING_POLL_INTERVAL_MS)); - } - } - - /** Get the container's {@link children resources}, waiting for loading to complete if necessary. */ - async gatherResources(timeoutMs: number = 10000): Promise { - let resources: T[] = []; - try { - await this.ensureDoneLoading(timeoutMs); - resources = this.children; - } catch (error) { - // should only be a timeout error: - this.logger.error(`Error getting resources: ${error}`); - } - return resources; - } -} diff --git a/src/viewProviders/flinkDatabase.test.ts b/src/viewProviders/flinkDatabase.test.ts index df173ffa1..494a1506a 100644 --- a/src/viewProviders/flinkDatabase.test.ts +++ b/src/viewProviders/flinkDatabase.test.ts @@ -29,7 +29,7 @@ import type { FlinkDatabaseResource } from "../models/flinkDatabaseResource"; import { FlinkDatabaseContainerLabel, FlinkDatabaseResourceContainer, -} from "../models/flinkDatabaseResourceContainer"; +} from "../models/containers/flinkDatabaseResourceContainer"; import { FlinkUdfTreeItem } from "../models/flinkUDF"; import type { CCloudFlinkDbKafkaCluster } from "../models/kafkaCluster"; import { CCloudKafkaCluster } from "../models/kafkaCluster"; @@ -251,7 +251,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should return the FlinkRelation parent for a FlinkRelationColumn", () => { const testRelation = TEST_FLINK_RELATION; const testColumn = TEST_VARCHAR_COLUMN; - viewProvider.relationsContainer.children = [testRelation]; + viewProvider.relationsContainer.setLoaded([testRelation]); const parent = viewProvider.getParent(testColumn); @@ -260,7 +260,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should return undefined when a FlinkRelationColumn parent is not found", () => { const testColumn = TEST_VARCHAR_COLUMN; - viewProvider.relationsContainer.children = []; + viewProvider.relationsContainer.setLoaded([]); const parent = viewProvider.getParent(testColumn); @@ -352,7 +352,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should reveal a FlinkArtifact by finding it in the artifacts container", async () => { const testArtifact = createFlinkArtifact({ id: "art1", name: "TestArtifact" }); - viewProvider.artifactsContainer.children = [testArtifact]; + viewProvider.artifactsContainer.setLoaded([testArtifact]); await viewProvider.revealResource(testArtifact); @@ -362,7 +362,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should reveal a FlinkUdf by finding it in the UDFs container", async () => { const testUdf = createFlinkUDF("TestUDF"); - viewProvider.udfsContainer.children = [testUdf]; + viewProvider.udfsContainer.setLoaded([testUdf]); await viewProvider.revealResource(testUdf); @@ -372,7 +372,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should reveal a FlinkRelation by finding it in the relations container", async () => { const testRelation = TEST_FLINK_RELATION; - viewProvider.relationsContainer.children = [testRelation]; + viewProvider.relationsContainer.setLoaded([testRelation]); await viewProvider.revealResource(testRelation); @@ -383,7 +383,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should reveal a FlinkRelationColumn by finding its parent relation", async () => { const testRelation = TEST_FLINK_RELATION; const testColumn = TEST_VARCHAR_COLUMN; - viewProvider.relationsContainer.children = [testRelation]; + viewProvider.relationsContainer.setLoaded([testRelation]); await viewProvider.revealResource(testColumn); @@ -394,7 +394,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should reveal a FlinkAIConnection by finding it in the AI connections container", async () => { const testConnection = createFlinkAIConnection("TestConnection"); - viewProvider.aiConnectionsContainer.children = [testConnection]; + viewProvider.aiConnectionsContainer.setLoaded([testConnection]); await viewProvider.revealResource(testConnection); @@ -404,7 +404,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should reveal a FlinkAITool by finding it in the AI tools container", async () => { const testTool = createFlinkAITool("TestTool"); - viewProvider.aiToolsContainer.children = [testTool]; + viewProvider.aiToolsContainer.setLoaded([testTool]); await viewProvider.revealResource(testTool); @@ -414,7 +414,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should reveal a FlinkAIModel by finding it in the AI models container", async () => { const testModel = createFlinkAIModel("TestModel"); - viewProvider.aiModelsContainer.children = [testModel]; + viewProvider.aiModelsContainer.setLoaded([testModel]); await viewProvider.revealResource(testModel); @@ -424,7 +424,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should reveal a FlinkAIAgent by finding it in the AI agents container", async () => { const testAgent = createFlinkAIAgent("TestAgent"); - viewProvider.aiAgentsContainer.children = [testAgent]; + viewProvider.aiAgentsContainer.setLoaded([testAgent]); await viewProvider.revealResource(testAgent); @@ -437,7 +437,7 @@ describe("viewProviders/flinkDatabase.ts", () => { id: "nope-this-belongs-somewhere-else", name: "TestArtifact", }); - viewProvider.artifactsContainer.children = []; + viewProvider.artifactsContainer.setLoaded([]); await viewProvider.revealResource(testArtifact); @@ -446,7 +446,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should use custom options when provided", async () => { const testArtifact = createFlinkArtifact({ id: "art1", name: "TestArtifact" }); - viewProvider.artifactsContainer.children = [testArtifact]; + viewProvider.artifactsContainer.setLoaded([testArtifact]); const customOptions = { select: false, @@ -461,7 +461,7 @@ describe("viewProviders/flinkDatabase.ts", () => { it("should handle treeView.reveal errors gracefully", async () => { const testArtifact = createFlinkArtifact({ id: "art1", name: "TestArtifact" }); - viewProvider.artifactsContainer.children = [testArtifact]; + viewProvider.artifactsContainer.setLoaded([testArtifact]); const error = new Error("TreeView reveal failed"); treeViewRevealStub.rejects(error); diff --git a/src/viewProviders/flinkDatabase.ts b/src/viewProviders/flinkDatabase.ts index 65ac65d53..c4ac9c513 100644 --- a/src/viewProviders/flinkDatabase.ts +++ b/src/viewProviders/flinkDatabase.ts @@ -22,7 +22,7 @@ import type { FlinkAIResource, FlinkDatabaseResource } from "../models/flinkData import { FlinkDatabaseContainerLabel, FlinkDatabaseResourceContainer, -} from "../models/flinkDatabaseResourceContainer"; +} from "../models/containers/flinkDatabaseResourceContainer"; import { FlinkRelation, FlinkRelationColumn } from "../models/flinkRelation"; import { FlinkUdf, FlinkUdfTreeItem } from "../models/flinkUDF"; import type { CCloudFlinkDbKafkaCluster } from "../models/kafkaCluster"; @@ -329,17 +329,13 @@ export class FlinkDatabaseViewProvider extends ParentedBaseViewProvider< this.logger.debug( `refreshing ${container.label} resources for ${database.name} (${database.id})...`, ); - // set initial loading state - container.isLoading = true; + container.setLoading(); this._onDidChangeTreeData.fire(container); let results: T[] = []; try { results = await loaderMethod(database, forceDeepRefresh); - // clear any loading/error state and only refresh the provided container to show updated items - container.children = results; - container.tooltip = new CustomMarkdownString(); - container.hasError = false; + container.setLoaded(results); this._onDidChangeTreeData.fire(container); } catch (error) { let errorMsg = String(error); @@ -353,12 +349,9 @@ export class FlinkDatabaseViewProvider extends ParentedBaseViewProvider< } const msg = `Failed to load ${container.label} for **${database.name}** (${database.id}):`; logError(error, `${msg} ${errorMsg}`); - // clear the loading state and show error info as tooltip (and icon through setting hasError) - container.children = []; - container.tooltip = new CustomMarkdownString() - .addWarning(msg) - .addCodeBlock(errorMsg, errorLanguage); - container.hasError = true; + container.setError( + new CustomMarkdownString().addWarning(msg).addCodeBlock(errorMsg, errorLanguage), + ); this._onDidChangeTreeData.fire(container); } return results; diff --git a/tests/unit/testResources/base.ts b/tests/unit/testResources/base.ts index efd0787b2..ac097319c 100644 --- a/tests/unit/testResources/base.ts +++ b/tests/unit/testResources/base.ts @@ -48,7 +48,7 @@ export function getTestEnvironmentIdForConnectionType( */ export function createTestResource( id: string, - name: string, + name?: string, connectionType: ConnectionType = ConnectionType.Ccloud, children?: BaseViewProviderData[], ): BaseViewProviderData { @@ -56,7 +56,7 @@ export function createTestResource( id, connectionId: getTestConnectionIdForType(connectionType), connectionType, - searchableText: () => name, + searchableText: () => name ?? id, children, }; } @@ -73,16 +73,16 @@ export interface TestParentedResource extends EnvironmentedBaseViewProviderData */ export function createParentedTestResource( id: string, - name: string, + name?: string, connectionType: ConnectionType = ConnectionType.Ccloud, ): TestParentedResource { const base: TestParentedResource = { id, - name, + name: name ?? id, connectionId: getTestConnectionIdForType(connectionType), connectionType, environmentId: getTestEnvironmentIdForConnectionType(connectionType), - searchableText: () => name, + searchableText: () => name ?? id, }; if (connectionType === ConnectionType.Ccloud) {