diff --git a/src/notebooks/deepnote/converters/inputConverters.ts b/src/notebooks/deepnote/converters/inputConverters.ts new file mode 100644 index 0000000000..29ec1b9766 --- /dev/null +++ b/src/notebooks/deepnote/converters/inputConverters.ts @@ -0,0 +1,201 @@ +import { NotebookCellData, NotebookCellKind } from 'vscode'; +import { z } from 'zod'; + +import type { BlockConverter } from './blockConverter'; +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; +import { + DeepnoteTextInputMetadataSchema, + DeepnoteTextareaInputMetadataSchema, + DeepnoteSelectInputMetadataSchema, + DeepnoteSliderInputMetadataSchema, + DeepnoteCheckboxInputMetadataSchema, + DeepnoteDateInputMetadataSchema, + DeepnoteDateRangeInputMetadataSchema, + DeepnoteFileInputMetadataSchema, + DeepnoteButtonMetadataSchema +} from '../deepnoteSchemas'; +import { parseJsonWithFallback } from '../dataConversionUtils'; +import { DEEPNOTE_VSCODE_RAW_CONTENT_KEY } from './constants'; + +export abstract class BaseInputBlockConverter implements BlockConverter { + abstract schema(): T; + abstract getSupportedType(): string; + abstract defaultConfig(): z.infer; + + applyChangesToBlock(block: DeepnoteBlock, cell: NotebookCellData): void { + block.content = ''; + + const config = this.schema().safeParse(parseJsonWithFallback(cell.value)); + + if (config.success !== true) { + block.metadata = { + ...(block.metadata ?? {}), + [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: cell.value + }; + return; + } + + if (block.metadata != null) { + delete block.metadata[DEEPNOTE_VSCODE_RAW_CONTENT_KEY]; + } + + block.metadata = { + ...(block.metadata ?? {}), + ...config.data + }; + } + + canConvert(blockType: string): boolean { + return blockType.toLowerCase() === this.getSupportedType(); + } + + convertToCell(block: DeepnoteBlock): NotebookCellData { + const deepnoteJupyterRawContentResult = z.string().safeParse(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY]); + const deepnoteMetadataResult = this.schema().safeParse(block.metadata); + + if (deepnoteMetadataResult.error != null) { + console.error('Error parsing deepnote input metadata:', deepnoteMetadataResult.error); + console.debug('Metadata:', JSON.stringify(block.metadata)); + } + + const configStr = deepnoteJupyterRawContentResult.success + ? deepnoteJupyterRawContentResult.data + : deepnoteMetadataResult.success + ? JSON.stringify(deepnoteMetadataResult.data, null, 2) + : JSON.stringify(this.defaultConfig(), null, 2); + + const cell = new NotebookCellData(NotebookCellKind.Code, configStr, 'json'); + + return cell; + } + + getSupportedTypes(): string[] { + return [this.getSupportedType()]; + } +} + +export class InputTextBlockConverter extends BaseInputBlockConverter { + private readonly DEFAULT_INPUT_TEXT_CONFIG = DeepnoteTextInputMetadataSchema.parse({}); + + schema() { + return DeepnoteTextInputMetadataSchema; + } + getSupportedType() { + return 'input-text'; + } + defaultConfig() { + return this.DEFAULT_INPUT_TEXT_CONFIG; + } +} + +export class InputTextareaBlockConverter extends BaseInputBlockConverter { + private readonly DEFAULT_INPUT_TEXTAREA_CONFIG = DeepnoteTextareaInputMetadataSchema.parse({}); + + schema() { + return DeepnoteTextareaInputMetadataSchema; + } + getSupportedType() { + return 'input-textarea'; + } + defaultConfig() { + return this.DEFAULT_INPUT_TEXTAREA_CONFIG; + } +} + +export class InputSelectBlockConverter extends BaseInputBlockConverter { + private readonly DEFAULT_INPUT_SELECT_CONFIG = DeepnoteSelectInputMetadataSchema.parse({}); + + schema() { + return DeepnoteSelectInputMetadataSchema; + } + getSupportedType() { + return 'input-select'; + } + defaultConfig() { + return this.DEFAULT_INPUT_SELECT_CONFIG; + } +} + +export class InputSliderBlockConverter extends BaseInputBlockConverter { + private readonly DEFAULT_INPUT_SLIDER_CONFIG = DeepnoteSliderInputMetadataSchema.parse({}); + + schema() { + return DeepnoteSliderInputMetadataSchema; + } + getSupportedType() { + return 'input-slider'; + } + defaultConfig() { + return this.DEFAULT_INPUT_SLIDER_CONFIG; + } +} + +export class InputCheckboxBlockConverter extends BaseInputBlockConverter { + private readonly DEFAULT_INPUT_CHECKBOX_CONFIG = DeepnoteCheckboxInputMetadataSchema.parse({}); + + schema() { + return DeepnoteCheckboxInputMetadataSchema; + } + getSupportedType() { + return 'input-checkbox'; + } + defaultConfig() { + return this.DEFAULT_INPUT_CHECKBOX_CONFIG; + } +} + +export class InputDateBlockConverter extends BaseInputBlockConverter { + private readonly DEFAULT_INPUT_DATE_CONFIG = DeepnoteDateInputMetadataSchema.parse({}); + + schema() { + return DeepnoteDateInputMetadataSchema; + } + getSupportedType() { + return 'input-date'; + } + defaultConfig() { + return this.DEFAULT_INPUT_DATE_CONFIG; + } +} + +export class InputDateRangeBlockConverter extends BaseInputBlockConverter { + private readonly DEFAULT_INPUT_DATE_RANGE_CONFIG = DeepnoteDateRangeInputMetadataSchema.parse({}); + + schema() { + return DeepnoteDateRangeInputMetadataSchema; + } + getSupportedType() { + return 'input-date-range'; + } + defaultConfig() { + return this.DEFAULT_INPUT_DATE_RANGE_CONFIG; + } +} + +export class InputFileBlockConverter extends BaseInputBlockConverter { + private readonly DEFAULT_INPUT_FILE_CONFIG = DeepnoteFileInputMetadataSchema.parse({}); + + schema() { + return DeepnoteFileInputMetadataSchema; + } + getSupportedType() { + return 'input-file'; + } + defaultConfig() { + return this.DEFAULT_INPUT_FILE_CONFIG; + } +} + +export class ButtonBlockConverter extends BaseInputBlockConverter { + private readonly DEFAULT_BUTTON_CONFIG = DeepnoteButtonMetadataSchema.parse({}); + + schema() { + return DeepnoteButtonMetadataSchema; + } + getSupportedType() { + return 'button'; + } + defaultConfig() { + return this.DEFAULT_BUTTON_CONFIG; + } +} diff --git a/src/notebooks/deepnote/converters/inputConverters.unit.test.ts b/src/notebooks/deepnote/converters/inputConverters.unit.test.ts new file mode 100644 index 0000000000..efa1dbe6f2 --- /dev/null +++ b/src/notebooks/deepnote/converters/inputConverters.unit.test.ts @@ -0,0 +1,1153 @@ +import { assert } from 'chai'; +import { NotebookCellData, NotebookCellKind } from 'vscode'; + +import type { DeepnoteBlock } from '../../../platform/deepnote/deepnoteTypes'; +import { + InputTextBlockConverter, + InputTextareaBlockConverter, + InputSelectBlockConverter, + InputSliderBlockConverter, + InputCheckboxBlockConverter, + InputDateBlockConverter, + InputDateRangeBlockConverter, + InputFileBlockConverter, + ButtonBlockConverter +} from './inputConverters'; +import { DEEPNOTE_VSCODE_RAW_CONTENT_KEY } from './constants'; + +suite('InputTextBlockConverter', () => { + let converter: InputTextBlockConverter; + + setup(() => { + converter = new InputTextBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts input-text block with metadata to JSON cell', () => { + const block: DeepnoteBlock = { + blockGroup: '92f21410c8c54ac0be7e4d2a544552ee', + content: '', + id: '70c6668216ce43cfb556e57247a31fb9', + metadata: { + deepnote_input_label: 'some display name', + deepnote_variable_name: 'input_1', + deepnote_variable_value: 'some text input', + deepnote_variable_default_value: 'some default value' + }, + sortingKey: 's', + type: 'input-text' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_input_label, 'some display name'); + assert.strictEqual(parsed.deepnote_variable_name, 'input_1'); + assert.strictEqual(parsed.deepnote_variable_value, 'some text input'); + assert.strictEqual(parsed.deepnote_variable_default_value, 'some default value'); + }); + + test('handles missing metadata with default config', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-text' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_input_label, ''); + assert.strictEqual(parsed.deepnote_variable_name, ''); + assert.strictEqual(parsed.deepnote_variable_value, ''); + assert.isNull(parsed.deepnote_variable_default_value); + }); + + test('uses raw content when available', () => { + const rawContent = '{"deepnote_variable_name": "custom"}'; + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + metadata: { + [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: rawContent + }, + sortingKey: 'a0', + type: 'input-text' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.value, rawContent); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON to block metadata', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'old content', + id: 'block-123', + sortingKey: 'a0', + type: 'input-text' + }; + const cellValue = JSON.stringify( + { + deepnote_input_label: 'new label', + deepnote_variable_name: 'new_var', + deepnote_variable_value: 'new value', + deepnote_variable_default_value: 'new default' + }, + null, + 2 + ); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_input_label, 'new label'); + assert.strictEqual(block.metadata?.deepnote_variable_name, 'new_var'); + assert.strictEqual(block.metadata?.deepnote_variable_value, 'new value'); + assert.strictEqual(block.metadata?.deepnote_variable_default_value, 'new default'); + }); + + test('handles invalid JSON by storing in raw content key', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'old content', + id: 'block-123', + sortingKey: 'a0', + type: 'input-text' + }; + const invalidJson = '{invalid json}'; + const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + }); + + test('clears raw content key when valid JSON is applied', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + metadata: { + [DEEPNOTE_VSCODE_RAW_CONTENT_KEY]: 'old raw content' + }, + sortingKey: 'a0', + type: 'input-text' + }; + const cellValue = JSON.stringify({ + deepnote_variable_name: 'var1' + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.isUndefined(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY]); + }); + + test('does not modify other block properties', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: 'old content', + id: 'block-123', + executionCount: 5, + sortingKey: 'a0', + type: 'input-text' + }; + const cellValue = JSON.stringify({ deepnote_variable_name: 'var' }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.id, 'block-123'); + assert.strictEqual(block.type, 'input-text'); + assert.strictEqual(block.sortingKey, 'a0'); + assert.strictEqual(block.executionCount, 5); + }); + }); +}); + +suite('InputTextareaBlockConverter', () => { + let converter: InputTextareaBlockConverter; + + setup(() => { + converter = new InputTextareaBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts input-textarea block with multiline value to JSON cell', () => { + const block: DeepnoteBlock = { + blockGroup: '2b5f9340349f4baaa5a3237331214352', + content: '', + id: 'cbfee3d709dc4592b3186e8e95adca55', + metadata: { + deepnote_variable_name: 'input_2', + deepnote_variable_value: 'some multiline\ntext input' + }, + sortingKey: 'v', + type: 'input-textarea' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, 'input_2'); + assert.strictEqual(parsed.deepnote_variable_value, 'some multiline\ntext input'); + }); + + test('handles missing metadata with default config', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-textarea' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, ''); + assert.strictEqual(parsed.deepnote_variable_value, ''); + assert.strictEqual(parsed.deepnote_input_label, ''); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON with multiline value to block metadata', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-textarea' + }; + const cellValue = JSON.stringify({ + deepnote_variable_name: 'textarea_var', + deepnote_variable_value: 'line1\nline2\nline3' + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_variable_name, 'textarea_var'); + assert.strictEqual(block.metadata?.deepnote_variable_value, 'line1\nline2\nline3'); + }); + + test('handles invalid JSON', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-textarea' + }; + const invalidJson = 'not json'; + const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + }); + }); +}); + +suite('InputSelectBlockConverter', () => { + let converter: InputSelectBlockConverter; + + setup(() => { + converter = new InputSelectBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts input-select block with single value', () => { + const block: DeepnoteBlock = { + blockGroup: 'ba248341bdd94b93a234777968bfedcf', + content: '', + id: '83cdcbd2ea5b462a900b6a3d7c8b04cf', + metadata: { + deepnote_input_label: '', + deepnote_variable_name: 'input_3', + deepnote_variable_value: 'Option 1', + deepnote_variable_options: ['Option 1', 'Option 2'], + deepnote_variable_select_type: 'from-options', + deepnote_variable_custom_options: ['Option 1', 'Option 2'], + deepnote_variable_selected_variable: '' + }, + sortingKey: 'x', + type: 'input-select' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, 'input_3'); + assert.strictEqual(parsed.deepnote_variable_value, 'Option 1'); + assert.deepStrictEqual(parsed.deepnote_variable_options, ['Option 1', 'Option 2']); + }); + + test('converts input-select block with multiple values', () => { + const block: DeepnoteBlock = { + blockGroup: '9f77387639cd432bb913890dea32b6c3', + content: '', + id: '748548e64442416bb9f3a9c5ec22c4de', + metadata: { + deepnote_input_label: 'some select display name', + deepnote_variable_name: 'input_4', + deepnote_variable_value: ['Option 1'], + deepnote_variable_options: ['Option 1', 'Option 2'], + deepnote_variable_select_type: 'from-options', + deepnote_allow_multiple_values: true, + deepnote_variable_custom_options: ['Option 1', 'Option 2'], + deepnote_variable_selected_variable: '' + }, + sortingKey: 'y', + type: 'input-select' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_allow_multiple_values, true); + assert.deepStrictEqual(parsed.deepnote_variable_value, ['Option 1']); + }); + + test('converts input-select block with allow empty values', () => { + const block: DeepnoteBlock = { + blockGroup: '146c4af1efb2448fa2d3b7cfbd30da77', + content: '', + id: 'a3521dd942d2407693b0202b55c935a7', + metadata: { + deepnote_input_label: 'allows empty value', + deepnote_variable_name: 'input_5', + deepnote_variable_value: 'Option 1', + deepnote_variable_options: ['Option 1', 'Option 2'], + deepnote_allow_empty_values: true, + deepnote_variable_select_type: 'from-options', + deepnote_variable_default_value: '', + deepnote_variable_custom_options: ['Option 1', 'Option 2'], + deepnote_variable_selected_variable: '' + }, + sortingKey: 'yU', + type: 'input-select' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_allow_empty_values, true); + assert.strictEqual(parsed.deepnote_variable_default_value, ''); + }); + + test('handles missing metadata with default config', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-select' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, ''); + assert.strictEqual(parsed.deepnote_variable_value, 'Option 1'); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON with single value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-select' + }; + const cellValue = JSON.stringify({ + deepnote_variable_name: 'select_var', + deepnote_variable_value: 'Option A', + deepnote_variable_options: ['Option A', 'Option B'] + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_variable_value, 'Option A'); + assert.deepStrictEqual(block.metadata?.deepnote_variable_options, ['Option A', 'Option B']); + }); + + test('applies valid JSON with array value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-select' + }; + const cellValue = JSON.stringify({ + deepnote_variable_value: ['Option 1', 'Option 2'], + deepnote_allow_multiple_values: true + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.deepStrictEqual(block.metadata?.deepnote_variable_value, ['Option 1', 'Option 2']); + assert.strictEqual(block.metadata?.deepnote_allow_multiple_values, true); + }); + + test('handles invalid JSON', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-select' + }; + const invalidJson = '{broken'; + const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + }); + }); +}); + +suite('InputSliderBlockConverter', () => { + let converter: InputSliderBlockConverter; + + setup(() => { + converter = new InputSliderBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts input-slider block with basic configuration', () => { + const block: DeepnoteBlock = { + blockGroup: 'e867ead6336e406992c632f315d8316b', + content: '', + id: 'ca9fb5f83cfc454e963d60aeb8060502', + metadata: { + deepnote_input_label: 'slider input value', + deepnote_slider_step: 1, + deepnote_variable_name: 'input_6', + deepnote_variable_value: '5', + deepnote_slider_max_value: 10, + deepnote_slider_min_value: 0, + deepnote_variable_default_value: '5' + }, + sortingKey: 'yj', + type: 'input-slider' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, 'input_6'); + assert.strictEqual(parsed.deepnote_variable_value, '5'); + assert.strictEqual(parsed.deepnote_slider_min_value, 0); + assert.strictEqual(parsed.deepnote_slider_max_value, 10); + assert.strictEqual(parsed.deepnote_slider_step, 1); + }); + + test('converts input-slider block with custom step size', () => { + const block: DeepnoteBlock = { + blockGroup: 'a28007ff7c7f4a9d831e7b92fe8f038c', + content: '', + id: '6b4ad1dc5dcd417cbecf5b0bcef7d5be', + metadata: { + deepnote_input_label: 'step size 2', + deepnote_slider_step: 2, + deepnote_variable_name: 'input_7', + deepnote_variable_value: '6', + deepnote_slider_max_value: 10, + deepnote_slider_min_value: 4 + }, + sortingKey: 'yr', + type: 'input-slider' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_slider_step, 2); + assert.strictEqual(parsed.deepnote_slider_min_value, 4); + assert.strictEqual(parsed.deepnote_variable_value, '6'); + }); + + test('handles missing metadata with default config', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-slider' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, ''); + assert.strictEqual(parsed.deepnote_slider_min_value, 0); + assert.strictEqual(parsed.deepnote_slider_max_value, 10); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON with slider configuration', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-slider' + }; + const cellValue = JSON.stringify({ + deepnote_variable_name: 'slider1', + deepnote_variable_value: '7', + deepnote_slider_min_value: 0, + deepnote_slider_max_value: 100, + deepnote_slider_step: 5 + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_variable_value, '7'); + assert.strictEqual(block.metadata?.deepnote_slider_min_value, 0); + assert.strictEqual(block.metadata?.deepnote_slider_max_value, 100); + assert.strictEqual(block.metadata?.deepnote_slider_step, 5); + }); + + test('handles numeric value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-slider' + }; + const cellValue = JSON.stringify({ + deepnote_variable_value: 42 + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + // Numeric values fail string schema validation, so stored in raw content + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], cellValue); + }); + + test('handles invalid JSON', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-slider' + }; + const invalidJson = 'invalid'; + const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + }); + }); +}); + +suite('InputCheckboxBlockConverter', () => { + let converter: InputCheckboxBlockConverter; + + setup(() => { + converter = new InputCheckboxBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts input-checkbox block to JSON cell', () => { + const block: DeepnoteBlock = { + blockGroup: '5dd57f6bb90b49ebb954f6247b26427d', + content: '', + id: '9f97163d58f14192985d47f89f695239', + metadata: { + deepnote_input_label: '', + deepnote_variable_name: 'input_8', + deepnote_variable_value: false + }, + sortingKey: 'yv', + type: 'input-checkbox' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, 'input_8'); + assert.strictEqual(parsed.deepnote_variable_value, false); + }); + + test('handles checkbox with true value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + metadata: { + deepnote_variable_name: 'check1', + deepnote_variable_value: true + }, + sortingKey: 'a0', + type: 'input-checkbox' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_value, true); + }); + + test('handles missing metadata with default config', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-checkbox' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, ''); + assert.strictEqual(parsed.deepnote_variable_value, false); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON with boolean value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-checkbox' + }; + const cellValue = JSON.stringify({ + deepnote_variable_name: 'checkbox1', + deepnote_variable_value: true + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_variable_value, true); + }); + + test('applies false value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-checkbox' + }; + const cellValue = JSON.stringify({ + deepnote_variable_value: false, + deepnote_variable_default_value: true + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.deepnote_variable_value, false); + assert.strictEqual(block.metadata?.deepnote_variable_default_value, true); + }); + + test('handles invalid JSON', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-checkbox' + }; + const invalidJson = '{]'; + const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + }); + }); +}); + +suite('InputDateBlockConverter', () => { + let converter: InputDateBlockConverter; + + setup(() => { + converter = new InputDateBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts input-date block to JSON cell', () => { + const block: DeepnoteBlock = { + blockGroup: 'e84010446b844a86a1f6bbe5d89dc798', + content: '', + id: '33bce14fafc1431d9293dacc62e6e504', + metadata: { + deepnote_input_label: '', + deepnote_variable_name: 'input_9', + deepnote_variable_value: '2025-10-13T00:00:00.000Z', + deepnote_input_date_version: 2 + }, + sortingKey: 'yx', + type: 'input-date' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, 'input_9'); + assert.strictEqual(parsed.deepnote_variable_value, '2025-10-13T00:00:00.000Z'); + assert.strictEqual(parsed.deepnote_input_date_version, 2); + }); + + test('handles missing metadata with default config', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-date' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, ''); + // Default value should be an ISO date string + assert.match(parsed.deepnote_variable_value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON with date value', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-date' + }; + const cellValue = JSON.stringify({ + deepnote_variable_name: 'date1', + deepnote_variable_value: '2025-12-31T00:00:00.000Z', + deepnote_input_date_version: 2 + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_variable_value, '2025-12-31T00:00:00.000Z'); + assert.strictEqual(block.metadata?.deepnote_input_date_version, 2); + }); + + test('handles invalid JSON', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-date' + }; + const invalidJson = 'not valid'; + const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + }); + }); +}); + +suite('InputDateRangeBlockConverter', () => { + let converter: InputDateRangeBlockConverter; + + setup(() => { + converter = new InputDateRangeBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts input-date-range block with absolute dates', () => { + const block: DeepnoteBlock = { + blockGroup: '1fe36d4de4f04fefbe80cdd0d1a3ad3b', + content: '', + id: '953d7ad89bbf42b38ee8ca1899c3b732', + metadata: { + deepnote_variable_name: 'input_10', + deepnote_variable_value: ['2025-10-06', '2025-10-16'] + }, + sortingKey: 'yy', + type: 'input-date-range' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, 'input_10'); + assert.deepStrictEqual(parsed.deepnote_variable_value, ['2025-10-06', '2025-10-16']); + }); + + test('converts input-date-range block with relative date', () => { + const block: DeepnoteBlock = { + blockGroup: '10e193de0aec4f3c80946edf358777e5', + content: '', + id: '322182f9673d45fda1c1aa13f8b02371', + metadata: { + deepnote_input_label: 'relative past 3 months', + deepnote_variable_name: 'input_11', + deepnote_variable_value: 'past3months' + }, + sortingKey: 'yyU', + type: 'input-date-range' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_input_label, 'relative past 3 months'); + assert.strictEqual(parsed.deepnote_variable_value, 'past3months'); + }); + + test('handles missing metadata with default config', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-date-range' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, ''); + assert.strictEqual(parsed.deepnote_variable_value, ''); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON with date range array', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-date-range' + }; + const cellValue = JSON.stringify({ + deepnote_variable_name: 'range1', + deepnote_variable_value: ['2025-01-01', '2025-12-31'] + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.deepStrictEqual(block.metadata?.deepnote_variable_value, ['2025-01-01', '2025-12-31']); + }); + + test('applies valid JSON with relative date string', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-date-range' + }; + const cellValue = JSON.stringify({ + deepnote_variable_value: 'past7days' + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.deepnote_variable_value, 'past7days'); + }); + + test('handles invalid JSON', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-date-range' + }; + const invalidJson = '{{bad}}'; + const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + }); + }); +}); + +suite('InputFileBlockConverter', () => { + let converter: InputFileBlockConverter; + + setup(() => { + converter = new InputFileBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts input-file block to JSON cell', () => { + const block: DeepnoteBlock = { + blockGroup: '651f4f5db96b43d5a6a1a492935fa08d', + content: '', + id: 'c20aa90dacad40b7817f5e7d2823ce88', + metadata: { + deepnote_input_label: 'csv file input', + deepnote_variable_name: 'input_12', + deepnote_variable_value: '', + deepnote_allowed_file_extensions: '.csv' + }, + sortingKey: 'yyj', + type: 'input-file' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_input_label, 'csv file input'); + assert.strictEqual(parsed.deepnote_variable_name, 'input_12'); + assert.strictEqual(parsed.deepnote_allowed_file_extensions, '.csv'); + }); + + test('handles missing metadata with default config', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-file' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_variable_name, ''); + assert.isNull(parsed.deepnote_allowed_file_extensions); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON with file extension', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-file' + }; + const cellValue = JSON.stringify({ + deepnote_variable_name: 'file1', + deepnote_allowed_file_extensions: '.pdf,.docx' + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_allowed_file_extensions, '.pdf,.docx'); + }); + + test('handles invalid JSON', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'input-file' + }; + const invalidJson = 'bad json'; + const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + }); + }); +}); + +suite('ButtonBlockConverter', () => { + let converter: ButtonBlockConverter; + + setup(() => { + converter = new ButtonBlockConverter(); + }); + + suite('convertToCell', () => { + test('converts button block with set_variable behavior', () => { + const block: DeepnoteBlock = { + blockGroup: '22e563550e734e75b35252e4975c3110', + content: '', + id: 'd1af4f0aea6943d2941d4a168b4d03f7', + metadata: { + deepnote_button_title: 'Run', + deepnote_variable_name: 'button_1', + deepnote_button_behavior: 'set_variable', + deepnote_button_color_scheme: 'blue' + }, + sortingKey: 'yyr', + type: 'button' + }; + + const cell = converter.convertToCell(block); + + assert.strictEqual(cell.kind, NotebookCellKind.Code); + assert.strictEqual(cell.languageId, 'json'); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_button_title, 'Run'); + assert.strictEqual(parsed.deepnote_button_behavior, 'set_variable'); + assert.strictEqual(parsed.deepnote_button_color_scheme, 'blue'); + }); + + test('converts button block with run behavior', () => { + const block: DeepnoteBlock = { + blockGroup: '2a1e97120eb24494adff278264625a4f', + content: '', + id: '6caaf767dc154528bbe3bb29f3c80f4e', + metadata: { + deepnote_button_title: 'Run notebook button', + deepnote_variable_name: 'button_1', + deepnote_button_behavior: 'run', + deepnote_button_color_scheme: 'blue' + }, + sortingKey: 'yyv', + type: 'button' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_button_title, 'Run notebook button'); + assert.strictEqual(parsed.deepnote_button_behavior, 'run'); + }); + + test('handles missing metadata with default config', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'button' + }; + + const cell = converter.convertToCell(block); + + const parsed = JSON.parse(cell.value); + assert.strictEqual(parsed.deepnote_button_title, 'Run'); + assert.strictEqual(parsed.deepnote_button_behavior, 'set_variable'); + }); + }); + + suite('applyChangesToBlock', () => { + test('applies valid JSON with button configuration', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'button' + }; + const cellValue = JSON.stringify({ + deepnote_button_title: 'Click Me', + deepnote_button_behavior: 'run', + deepnote_button_color_scheme: 'red' + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.content, ''); + assert.strictEqual(block.metadata?.deepnote_button_title, 'Click Me'); + assert.strictEqual(block.metadata?.deepnote_button_behavior, 'run'); + assert.strictEqual(block.metadata?.deepnote_button_color_scheme, 'red'); + }); + + test('applies different color schemes', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'button' + }; + const cellValue = JSON.stringify({ + deepnote_button_color_scheme: 'green' + }); + const cell = new NotebookCellData(NotebookCellKind.Code, cellValue, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.deepnote_button_color_scheme, 'green'); + }); + + test('handles invalid JSON', () => { + const block: DeepnoteBlock = { + blockGroup: 'test-group', + content: '', + id: 'block-123', + sortingKey: 'a0', + type: 'button' + }; + const invalidJson = '}{'; + const cell = new NotebookCellData(NotebookCellKind.Code, invalidJson, 'json'); + + converter.applyChangesToBlock(block, cell); + + assert.strictEqual(block.metadata?.[DEEPNOTE_VSCODE_RAW_CONTENT_KEY], invalidJson); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteDataConverter.ts b/src/notebooks/deepnote/deepnoteDataConverter.ts index 4c5508d2db..eb26e6d955 100644 --- a/src/notebooks/deepnote/deepnoteDataConverter.ts +++ b/src/notebooks/deepnote/deepnoteDataConverter.ts @@ -15,6 +15,17 @@ import { TextBlockConverter } from './converters/textBlockConverter'; import type { Field } from 'vega-lite/build/src/channeldef'; import type { LayerSpec, TopLevel } from 'vega-lite/build/src/spec'; import { ChartBigNumberBlockConverter } from './converters/chartBigNumberBlockConverter'; +import { + InputTextBlockConverter, + InputTextareaBlockConverter, + InputSelectBlockConverter, + InputSliderBlockConverter, + InputCheckboxBlockConverter, + InputDateBlockConverter, + InputDateRangeBlockConverter, + InputFileBlockConverter, + ButtonBlockConverter +} from './converters/inputConverters'; import { CHART_BIG_NUMBER_MIME_TYPE } from '../../platform/deepnote/deepnoteConstants'; /** @@ -28,6 +39,15 @@ export class DeepnoteDataConverter { this.registry.register(new CodeBlockConverter()); this.registry.register(new MarkdownBlockConverter()); this.registry.register(new ChartBigNumberBlockConverter()); + this.registry.register(new InputTextBlockConverter()); + this.registry.register(new InputTextareaBlockConverter()); + this.registry.register(new InputSelectBlockConverter()); + this.registry.register(new InputSliderBlockConverter()); + this.registry.register(new InputCheckboxBlockConverter()); + this.registry.register(new InputDateBlockConverter()); + this.registry.register(new InputDateRangeBlockConverter()); + this.registry.register(new InputFileBlockConverter()); + this.registry.register(new ButtonBlockConverter()); this.registry.register(new SqlBlockConverter()); this.registry.register(new TextBlockConverter()); this.registry.register(new VisualizationBlockConverter()); diff --git a/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.ts b/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.ts new file mode 100644 index 0000000000..9b0bd542a0 --- /dev/null +++ b/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + Disposable, + notebooks, + NotebookCell, + NotebookCellStatusBarItem, + NotebookCellStatusBarItemProvider +} from 'vscode'; +import { IExtensionSyncActivationService } from '../../platform/activation/types'; +import { injectable } from 'inversify'; +import type { Pocket } from '../../platform/deepnote/pocket'; + +/** + * Provides status bar items for Deepnote input block cells to display their block type. + * Shows the type of input (e.g., "input-text", "input-slider", "button") in the cell status bar. + */ +@injectable() +export class DeepnoteInputBlockCellStatusBarItemProvider + implements NotebookCellStatusBarItemProvider, IExtensionSyncActivationService +{ + private readonly disposables: Disposable[] = []; + + // List of supported Deepnote input block types + private readonly INPUT_BLOCK_TYPES = [ + 'input-text', + 'input-textarea', + 'input-select', + 'input-slider', + 'input-checkbox', + 'input-date', + 'input-date-range', + 'input-file', + 'button' + ]; + + activate(): void { + // Register the status bar item provider for Deepnote notebooks + this.disposables.push(notebooks.registerNotebookCellStatusBarItemProvider('deepnote', this)); + } + + provideCellStatusBarItems(cell: NotebookCell): NotebookCellStatusBarItem | undefined { + // Check if this cell is a Deepnote input block + // Get the block type from the __deepnotePocket metadata field + const pocket = cell.metadata?.__deepnotePocket as Pocket | undefined; + const blockType = pocket?.type; + + if (!blockType || !this.isInputBlock(blockType)) { + return undefined; + } + + const formattedName = this.formatBlockTypeName(blockType); + + // Create a status bar item showing the block type + // Using alignment value 2 (NotebookCellStatusBarAlignment.Right) + const statusBarItem: NotebookCellStatusBarItem = { + text: formattedName, + alignment: 2, // NotebookCellStatusBarAlignment.Right + tooltip: `Deepnote ${formattedName}` + }; + + return statusBarItem; + } + + /** + * Checks if the given block type is a Deepnote input block + */ + private isInputBlock(blockType: string): boolean { + return this.INPUT_BLOCK_TYPES.includes(blockType.toLowerCase()); + } + + /** + * Formats the block type name for display (e.g., "input-text" -> "Input Text") + */ + private formatBlockTypeName(blockType: string): string { + return blockType + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.unit.test.ts b/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.unit.test.ts new file mode 100644 index 0000000000..0870f8225f --- /dev/null +++ b/src/notebooks/deepnote/deepnoteInputBlockCellStatusBarProvider.unit.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnoteInputBlockCellStatusBarProvider'; +import { NotebookCell, NotebookCellKind, NotebookDocument } from 'vscode'; +import { Uri } from 'vscode'; + +suite('DeepnoteInputBlockCellStatusBarItemProvider', () => { + let provider: DeepnoteInputBlockCellStatusBarItemProvider; + + setup(() => { + provider = new DeepnoteInputBlockCellStatusBarItemProvider(); + }); + + teardown(() => { + provider.dispose(); + }); + + function createMockCell(metadata?: Record): NotebookCell { + return { + index: 0, + notebook: {} as NotebookDocument, + kind: NotebookCellKind.Code, + document: { + uri: Uri.file('/test/notebook.deepnote'), + fileName: '/test/notebook.deepnote', + isUntitled: false, + languageId: 'json', + version: 1, + isDirty: false, + isClosed: false, + getText: () => '', + save: async () => true, + eol: 1, + lineCount: 1, + lineAt: () => ({}) as any, + offsetAt: () => 0, + positionAt: () => ({}) as any, + validateRange: () => ({}) as any, + validatePosition: () => ({}) as any + } as any, + metadata: metadata || {}, + outputs: [], + executionSummary: undefined + } as any; + } + + suite('Input Block Type Detection', () => { + test('Should return status bar item for input-text block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-text' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Input Text'); + }); + + test('Should return status bar item for input-textarea block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-textarea' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Input Textarea'); + }); + + test('Should return status bar item for input-select block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-select' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Input Select'); + }); + + test('Should return status bar item for input-slider block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-slider' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Input Slider'); + }); + + test('Should return status bar item for input-checkbox block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-checkbox' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Input Checkbox'); + }); + + test('Should return status bar item for input-date block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-date' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Input Date'); + }); + + test('Should return status bar item for input-date-range block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-date-range' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Input Date Range'); + }); + + test('Should return status bar item for input-file block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-file' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Input File'); + }); + + test('Should return status bar item for button block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'button' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Button'); + }); + }); + + suite('Non-Input Block Types', () => { + test('Should return undefined for code block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'code' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.be.undefined; + }); + + test('Should return undefined for sql block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'sql' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.be.undefined; + }); + + test('Should return undefined for markdown block', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'text-cell-p' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.be.undefined; + }); + + test('Should return undefined for cell with no type metadata', () => { + const cell = createMockCell({}); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.be.undefined; + }); + + test('Should return undefined for cell with undefined metadata', () => { + const cell = createMockCell(undefined); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.be.undefined; + }); + }); + + suite('Status Bar Item Properties', () => { + test('Should have correct tooltip for input-text', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-text' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item?.tooltip).to.equal('Deepnote Input Text'); + }); + + test('Should have correct tooltip for button', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'button' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item?.tooltip).to.equal('Deepnote Button'); + }); + + test('Should format multi-word block types correctly', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'input-date-range' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item?.text).to.equal('Input Date Range'); + expect(item?.tooltip).to.equal('Deepnote Input Date Range'); + }); + }); + + suite('Case Insensitivity', () => { + test('Should handle uppercase block type', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'INPUT-TEXT' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('INPUT TEXT'); + }); + + test('Should handle mixed case block type', () => { + const cell = createMockCell({ __deepnotePocket: { type: 'Input-Text' } }); + const item = provider.provideCellStatusBarItems(cell); + + expect(item).to.not.be.undefined; + expect(item?.text).to.equal('Input Text'); + }); + }); +}); diff --git a/src/notebooks/deepnote/deepnoteSchemas.ts b/src/notebooks/deepnote/deepnoteSchemas.ts index 34da622882..70bab382ff 100644 --- a/src/notebooks/deepnote/deepnoteSchemas.ts +++ b/src/notebooks/deepnote/deepnoteSchemas.ts @@ -43,5 +43,173 @@ export const DeepnoteBigNumberMetadataSchema = z.object({ .transform((val) => val ?? false) }); +// Base schema with common fields for all input types +const DeepnoteBaseInputMetadataSchema = z.object({ + deepnote_variable_name: z + .string() + .nullish() + .transform((val) => val ?? '') +}); + +// Extended base schema with label (used by most input types) +const DeepnoteBaseInputWithLabelMetadataSchema = DeepnoteBaseInputMetadataSchema.extend({ + deepnote_input_label: z + .string() + .nullish() + .transform((val) => val ?? '') +}); + +export const DeepnoteTextInputMetadataSchema = DeepnoteBaseInputWithLabelMetadataSchema.extend({ + deepnote_variable_value: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_variable_default_value: z + .string() + .nullish() + .transform((val) => val ?? null) +}); + +export const DeepnoteTextareaInputMetadataSchema = DeepnoteBaseInputWithLabelMetadataSchema.extend({ + deepnote_variable_value: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_variable_default_value: z + .string() + .nullish() + .transform((val) => val ?? '') +}); + +export const DEEPNOTE_SELECT_INPUT_DEFAULT_OPTIONS = ['Option 1', 'Option 2'] as const; + +export const DeepnoteSelectInputMetadataSchema = DeepnoteBaseInputWithLabelMetadataSchema.extend({ + deepnote_variable_value: z + .union([z.string(), z.array(z.string())]) + .nullish() + .transform((val) => val ?? DEEPNOTE_SELECT_INPUT_DEFAULT_OPTIONS[0]), + deepnote_variable_default_value: z + .union([z.string(), z.array(z.string())]) + .nullish() + .transform((val) => val ?? null), + deepnote_variable_options: z + .array(z.string()) + .nullish() + .transform((val) => val ?? DEEPNOTE_SELECT_INPUT_DEFAULT_OPTIONS), + deepnote_variable_custom_options: z + .array(z.string()) + .nullish() + .transform((val) => val ?? DEEPNOTE_SELECT_INPUT_DEFAULT_OPTIONS), + deepnote_variable_select_type: z + .enum(['from-options', 'from-variable']) + // .string() + .nullish() + .transform((val) => val ?? null), + deepnote_allow_multiple_values: z + .boolean() + .nullish() + .transform((val) => val ?? false), + deepnote_allow_empty_values: z + .boolean() + .nullish() + .transform((val) => val ?? false), + deepnote_variable_selected_variable: z + .string() + .nullish() + .transform((val) => val ?? '') +}); + +export const DeepnoteSliderInputMetadataSchema = DeepnoteBaseInputWithLabelMetadataSchema.extend({ + deepnote_variable_value: z + .string() + .nullish() + .transform((val) => val ?? '5'), + deepnote_slider_min_value: z + .number() + .nullish() + .transform((val) => val ?? 0), + deepnote_slider_max_value: z + .number() + .nullish() + .transform((val) => val ?? 10), + deepnote_slider_step: z + .number() + .nullish() + .transform((val) => val ?? 1), + deepnote_variable_default_value: z + .string() + .nullish() + .transform((val) => val ?? null) +}); + +export const DeepnoteCheckboxInputMetadataSchema = DeepnoteBaseInputWithLabelMetadataSchema.extend({ + deepnote_variable_value: z + .boolean() + .nullish() + .transform((val) => val ?? false), + deepnote_variable_default_value: z + .boolean() + .nullish() + .transform((val) => val ?? null) +}); + +export function getStartOfDayDate(): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; +} + +export const DeepnoteDateInputMetadataSchema = DeepnoteBaseInputWithLabelMetadataSchema.extend({ + deepnote_variable_value: z + .string() + .nullish() + .transform((val) => val ?? getStartOfDayDate().toISOString()), + deepnote_variable_default_value: z + .string() + .nullish() + .transform((val) => val ?? null), + deepnote_input_date_version: z + .number() + .nullish() + .transform((val) => val ?? 2) +}); + +export const DeepnoteDateRangeInputMetadataSchema = DeepnoteBaseInputWithLabelMetadataSchema.extend({ + deepnote_variable_value: z + .union([z.string(), z.tuple([z.string(), z.string()])]) + .nullish() + .transform((val) => val ?? ''), + deepnote_variable_default_value: z + .union([z.string(), z.tuple([z.string(), z.string()])]) + .nullish() + .transform((val) => val ?? null) +}); + +export const DeepnoteFileInputMetadataSchema = DeepnoteBaseInputWithLabelMetadataSchema.extend({ + deepnote_variable_value: z + .string() + .nullish() + .transform((val) => val ?? ''), + deepnote_allowed_file_extensions: z + .string() + .nullish() + .transform((val) => val ?? null) +}); + +export const DeepnoteButtonMetadataSchema = DeepnoteBaseInputMetadataSchema.extend({ + deepnote_button_title: z + .string() + .nullish() + .transform((val) => val ?? 'Run'), + deepnote_button_behavior: z + .enum(['run', 'set_variable']) + .nullish() + .transform((val) => val ?? 'set_variable'), + deepnote_button_color_scheme: z + .enum(['blue', 'red', 'neutral', 'green', 'yellow']) + .nullish() + .transform((val) => val ?? 'blue') +}); + export type DeepnoteChartBigNumberOutput = z.infer; export type DeepnoteBigNumberMetadata = z.infer; diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 1d51e66450..651bc92147 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -66,6 +66,7 @@ import { DeepnoteKernelAutoSelector } from './deepnote/deepnoteKernelAutoSelecto import { DeepnoteServerProvider } from '../kernels/deepnote/deepnoteServerProvider.node'; import { DeepnoteInitNotebookRunner, IDeepnoteInitNotebookRunner } from './deepnote/deepnoteInitNotebookRunner.node'; import { DeepnoteRequirementsHelper, IDeepnoteRequirementsHelper } from './deepnote/deepnoteRequirementsHelper.node'; +import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; import { SqlIntegrationStartupCodeProvider } from './deepnote/integrations/sqlIntegrationStartupCodeProvider'; import { DeepnoteCellCopyHandler } from './deepnote/deepnoteCellCopyHandler'; @@ -168,6 +169,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addBinding(IDeepnoteKernelAutoSelector, IExtensionSyncActivationService); serviceManager.addSingleton(IDeepnoteInitNotebookRunner, DeepnoteInitNotebookRunner); serviceManager.addSingleton(IDeepnoteRequirementsHelper, DeepnoteRequirementsHelper); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteInputBlockCellStatusBarItemProvider + ); // File export/import serviceManager.addSingleton(IFileConverter, FileConverter); diff --git a/src/notebooks/serviceRegistry.web.ts b/src/notebooks/serviceRegistry.web.ts index dc67a169af..04752612eb 100644 --- a/src/notebooks/serviceRegistry.web.ts +++ b/src/notebooks/serviceRegistry.web.ts @@ -48,6 +48,7 @@ import { IIntegrationStorage, IIntegrationWebviewProvider } from './deepnote/integrations/types'; +import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { @@ -107,6 +108,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IIntegrationDetector, IntegrationDetector); serviceManager.addSingleton(IIntegrationWebviewProvider, IntegrationWebviewProvider); serviceManager.addSingleton(IIntegrationManager, IntegrationManager); + serviceManager.addSingleton( + IExtensionSyncActivationService, + DeepnoteInputBlockCellStatusBarItemProvider + ); serviceManager.addSingleton( IExtensionSyncActivationService, SqlCellStatusBarProvider