Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/components/tree-select/__test__/empty-options.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import path from 'path';
import simulate from 'miniprogram-simulate';

describe('TreeSelect empty options', () => {
const id = load(path.resolve(__dirname, '../tree-select'));
const options = [
{ label: 'First parent', value: 1, children: [{ label: 'First leaf', value: 11 }] },
{ label: 'Second parent', value: 2, children: [{ label: 'Second leaf', value: 22 }] },
];
let component;

function render(props = {}) {
component = simulate.render(id, { options, ...props });
component.attach(document.createElement('parent-wrapper'));
return component;
}

afterEach(() => component?.detach());

it.each([
['value', { value: [2, 22] }],
['customValue', { value: [1, 11], customValue: [2, 22] }],
['multiple', { value: [2, [22]], multiple: true }],
['defaultValue', { defaultValue: [2, 22] }],
])('removes rendered columns when %s options become empty', async (_, props) => {
render(props);
expect(component.dom.textContent).toContain('Second leaf');
const selected = JSON.parse(JSON.stringify(component.data.innerValue));
const change = jest.fn();
component.addEventListener('change', change);
component.setData({ options: [] });
await simulate.sleep(10);
expect(component.data.treeOptions).toEqual([]);
expect(component.dom.textContent).not.toContain('parent');
expect(component.dom.textContent).not.toContain('leaf');
expect(component.data.innerValue).toEqual(selected);
expect(change).not.toHaveBeenCalled();
});

it('clears columns with custom keys', async () => {
render({
options: [{ name: 'Named parent', id: 2, nodes: [{ name: 'Named leaf', id: 22 }] }],
keys: { label: 'name', value: 'id', children: 'nodes' },
value: [2, 22],
});
expect(component.dom.textContent).toContain('Named leaf');
component.setData({ options: [] });
await simulate.sleep(10);
expect(component.data.treeOptions).toEqual([]);
expect(component.dom.textContent).not.toContain('Named');
});

it('renders no options when initially empty', () => {
render({ options: [], value: [2, 22] });
expect(component.data.treeOptions || []).toEqual([]);
expect(component.dom.textContent.trim()).toBe('');
});

it('restores the selected branch when options return', async () => {
render({ value: [2, 22] });
component.setData({ options: [] });
await simulate.sleep(10);
component.setData({ options });
await simulate.sleep(10);
expect(component.data.innerValue).toEqual([2, 22]);
expect(component.dom.textContent).toContain('Second leaf');
expect(component.dom.textContent).not.toContain('First leaf');
});

it('renders asynchronously supplied options', async () => {
render({ options: [], value: [2, 22] });
component.setData({ options });
await simulate.sleep(10);
expect(component.data.innerValue).toEqual([2, 22]);
expect(component.dom.textContent).toContain('Second leaf');
});
});
101 changes: 101 additions & 0 deletions packages/components/tree-select/__test__/zero-values.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import path from 'path';
import simulate from 'miniprogram-simulate';

describe('TreeSelect falsy node values', () => {
const treeSelect = load(path.resolve(__dirname, '../tree-select'));
let component;

const options = [
{ label: 'Other parent', value: 1, children: [{ label: 'Other leaf', value: 11 }] },
{ label: 'Zero parent', value: 0, children: [{ label: 'Zero leaf', value: 5 }] },
];

function mount(data = {}) {
const id = simulate.load({
template: `<t-tree-select id="tree" options="{{options}}" value="{{value}}" custom-value="{{customValue}}" keys="{{keys}}" multiple="{{multiple}}" bind:change="onChange" />`,
usingComponents: { 't-tree-select': treeSelect },
data: { options, value: [0, 5], ...data },
methods: {
onChange(event) {
this.setData({ value: event.detail.value });
},
},
});
component = simulate.render(id);
component.attach(document.createElement('parent-wrapper'));
return component.querySelector('#tree');
}

afterEach(() => {
component?.detach();
});

it.each([0, ''])('renders the matching children for value %p', (value) => {
const tree = mount({
options: [options[0], { ...options[1], value }],
value: [value, 5],
});
expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]);
expect(tree.data.innerValue).toEqual([value, 5]);
expect(component.dom.textContent).toContain('Zero leaf');
expect(component.dom.textContent).not.toContain('Other leaf');
});

it('keeps a zero customValue ahead of a nonzero value', () => {
const tree = mount({ value: [1, 11], customValue: [0, 5] });
expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]);
expect(tree.data.innerValue).toEqual([0, 5]);
expect(component.dom.textContent).toContain('Zero leaf');
});

it('follows a zero value in the middle column', () => {
const tree = mount({ options: [{ label: 'Root', value: 7, children: options }], value: [7, 0, 5] });
expect(tree.data.treeOptions[2].map((item) => item.value)).toEqual([5]);
expect(tree.data.innerValue).toEqual([7, 0, 5]);
expect(component.dom.textContent).toContain('Zero leaf');
});

it('supports zero values with custom keys', () => {
const tree = mount({
keys: { value: 'id', label: 'name', children: 'items' },
options: [
{ name: 'Other parent', id: 1, items: [{ name: 'Other leaf', id: 11 }] },
{ name: 'Zero parent', id: 0, items: [{ name: 'Zero leaf', id: 5 }] },
],
});
expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]);
expect(component.dom.textContent).toContain('Zero leaf');
});

it('preserves the selected leaf array in multiple mode', () => {
const tree = mount({ value: [0, [5]], multiple: true });
expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]);
expect(tree.data.innerValue).toEqual([0, [5]]);
expect(component.dom.textContent).toContain('Zero leaf');
});

it('updates the rendered children after clicking the zero parent', async () => {
const tree = mount({ value: [1, 11] });
expect(component.dom.textContent).toContain('Other leaf');
const zeroParent = component.querySelector('#tree >>> #scroll-to-0');
expect(zeroParent).toBeTruthy();
zeroParent.dispatchEvent('tap');
await simulate.sleep(10);
expect(tree.data.innerValue[0]).toBe(0);
expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([5]);
expect(component.dom.textContent).toContain('Zero leaf');
expect(component.dom.textContent).not.toContain('Other leaf');
});

it.each([[], null, undefined, [99]])('retains the first branch fallback for %p', (value) => {
const tree = mount({ value });
expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([11]);
expect(component.dom.textContent).toContain('Other leaf');
});

it('keeps customValue=[] unselected instead of applying default values', () => {
const tree = mount({ value: [], customValue: [] });
expect(tree.data.innerValue).toEqual([]);
expect(tree.data.treeOptions[1].map((item) => item.value)).toEqual([11]);
});
});
7 changes: 5 additions & 2 deletions packages/components/tree-select/tree-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ export default class TreeSelect extends SuperComponent {
buildTreeOptions() {
const { options, value, customValue, multiple, keys } = this.data;

if (!options.length) return;
if (!options.length) {
this.setData({ treeOptions: [] });
return;
}

const treeOptions: TreeOptionData[][] = [];

Expand All @@ -75,7 +78,7 @@ export default class TreeSelect extends SuperComponent {
treeOptions.push(currentLevelOptions);

const currentValue = customValue?.[level] ?? value?.[level];
currentNode = currentValue
currentNode = isDef(currentValue)
? (currentLevelOptions.find((child) => child.value === currentValue) ?? currentLevelOptions[0])
: currentLevelOptions[0];
}
Expand Down
189 changes: 189 additions & 0 deletions packages/uniapp-components/tree-select/__test__/value.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import fs from 'fs';
import path from 'path';
import vm from 'vm';
import { transformSync } from '@babel/core';
import { parse } from '@vue/compiler-sfc';
import { createApp, h, nextTick, reactive } from 'vue';

global.uni = {
getWindowInfo: () => ({}),
getAppBaseInfo: () => ({}),
getDeviceInfo: () => ({}),
getSystemInfoSync: () => ({}),
};

// Jest has no Vue transformer. Run the actual SFC script and Vue watchers while
// stubbing presentation-only imports; the component's helpers remain unchanged.
const filename = path.resolve(__dirname, '../tree-select.vue');
const { descriptor } = parse(fs.readFileSync(filename, 'utf8'));
const { code } = transformSync(descriptor.script.content, {
filename,
configFile: false,
babelrc: false,
plugins: ['@babel/plugin-transform-modules-commonjs'],
});
const componentModule = { exports: {} };
vm.runInThisContext(`(function(require, module, exports) { ${code}\n })`, { filename })(
(id) => {
if (/^\.\.\/(checkbox|radio|scroll-view|side-bar)/.test(id) || id.endsWith('.wxs')) return {};
// eslint-disable-next-line import/no-dynamic-require, global-require
return require(path.resolve(path.dirname(filename), id));
},
componentModule,
componentModule.exports,
);
const TreeSelect = { ...componentModule.exports.default, render: () => null };

const options = [
{ label: 'First', value: 1, children: [{ value: 11 }, { value: 12 }] },
{ label: 'Second', value: 2, children: [{ value: 22 }, { value: 23 }] },
];

describe('uniapp TreeSelect external values', () => {
let app;
let tree;
let state;
let container;
let change;
let update;

async function mount(props, model = false) {
state = reactive({ options, ...props });
change = jest.fn();
update = jest.fn((value) => {
if (model) state.value = value;
});
app = createApp({
render: () =>
h(TreeSelect, {
...state,
ref: (instance) => {
tree = instance;
},
onChange: change,
'onUpdate:value': update,
}),
});
container = document.createElement('div');
document.body.appendChild(container);
app.mount(container);
await nextTick();
}

afterEach(() => {
app?.unmount();
container?.remove();
});

function selection() {
return { value: tree.innerValue, leaves: tree.treeOptions[1].map((item) => item.value) };
}

it.each(['value', 'customValue'])('responds to replaced %s without emitting a change', async (key) => {
await mount({ [key]: [1, 11] });
state[key] = [2, 22];
await nextTick();
expect(selection()).toEqual({ value: [2, 22], leaves: [22, 23] });
expect(change).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});

it.each(['value', 'customValue'])('responds to nested mutations of %s', async (key) => {
await mount({ [key]: [1, 11] });
state[key].splice(0, 2, 2, 23);
await nextTick();
expect(selection()).toEqual({ value: [2, 23], leaves: [22, 23] });
});

it('resets value to the default first selection', async () => {
await mount({ value: [2, 22] });
state.value = [];
await nextTick();
expect(selection()).toEqual({ value: [1, 11], leaves: [11, 12] });
});

it('keeps an empty customValue unselected', async () => {
await mount({ value: [1, 11], customValue: [2, 22] });
state.customValue = [];
await nextTick();
expect(selection()).toEqual({ value: [], leaves: [11, 12] });
});

it('keeps customValue priority and restores the latest value when removed', async () => {
await mount({ value: [1, 11], customValue: [2, 22] });
state.value = [1, 12];
await nextTick();
expect(selection()).toEqual({ value: [2, 22], leaves: [22, 23] });
state.customValue = undefined;
await nextTick();
expect(selection()).toEqual({ value: [1, 12], leaves: [11, 12] });
expect(change).not.toHaveBeenCalled();
});

it('restores defaultValue when customValue is removed without a value', async () => {
await mount({ defaultValue: [1, 12], customValue: [2, 22] });
state.customValue = undefined;
await nextTick();
expect(selection()).toEqual({ value: [1, 12], leaves: [11, 12] });
});

it('preserves uncontrolled selection after an interaction and options update', async () => {
await mount({ defaultValue: [1, 11] });
tree.handleChange({ value: 12, level: 1, type: 'single' });
await nextTick();
state.options = [...options];
await nextTick();
expect(selection()).toEqual({ value: [1, 12], leaves: [11, 12] });
expect(state.value).toBeUndefined();
expect(change).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledTimes(1);
});

it('does not turn defaultValue into a controlled prop', async () => {
await mount({ defaultValue: [1, 11] });
state.defaultValue = [2, 22];
await nextTick();
expect(selection()).toEqual({ value: [1, 11], leaves: [11, 12] });
});

it('restores the uncontrolled selection after a temporary customValue override', async () => {
await mount({ defaultValue: [1, 11] });
tree.handleChange({ value: 12, level: 1, type: 'single' });
await nextTick();
state.customValue = [2, 22];
await nextTick();
expect(selection()).toEqual({ value: [2, 22], leaves: [22, 23] });
state.defaultValue = [2, 23];
state.customValue = undefined;
await nextTick();
expect(selection()).toEqual({ value: [1, 12], leaves: [11, 12] });
expect(change).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledTimes(1);
});

it('does not emit again when a v-model update reaches the value watcher', async () => {
await mount({ value: [1, 11] }, true);
tree.onRootChange({ value: 2 });
await nextTick();
expect(tree.treeOptions[1].map((item) => item.value)).toEqual([22, 23]);
expect(state.value[0]).toBe(2);
expect(change).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledTimes(1);
});

it.each(['value', 'customValue'])('preserves multiple selection when %s changes', async (key) => {
await mount({ multiple: true, [key]: [1, [11]] });
state[key] = [2, [22, 23]];
await nextTick();
expect(selection()).toEqual({ value: [2, [22, 23]], leaves: [22, 23] });
});

it('uses the latest value when asynchronous options arrive', async () => {
await mount({ options: [], value: [1, 11] });
state.value = [2, 22];
await nextTick();
state.options = options;
await nextTick();
expect(selection()).toEqual({ value: [2, 22], leaves: [22, 23] });
});
});
Loading