Skip to content

Commit 22f8d5a

Browse files
committed
feat: add tests for list property
1 parent 721ed78 commit 22f8d5a

5 files changed

Lines changed: 287 additions & 4 deletions

File tree

examples/public/db_list_test.riv

788 KB
Binary file not shown.

examples/src/components/DataBindingTests.stories.tsx

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
22
import type { Meta, StoryObj } from '@storybook/react';
33
import { within, expect, waitFor, userEvent } from '@storybook/test';
44

5-
import { StringPropertyTest, NumberPropertyTest, BooleanPropertyTest, ColorPropertyTest, EnumPropertyTest, NestedViewModelTest, TriggerPropertyTest, PersonForm, PersonInstances, ImagePropertyTest } from './DataBindingTests';
5+
import { StringPropertyTest, NumberPropertyTest, BooleanPropertyTest, ColorPropertyTest, EnumPropertyTest, NestedViewModelTest, TriggerPropertyTest, PersonForm, PersonInstances, ImagePropertyTest, TodoListTest } from './DataBindingTests';
66

77
const meta: Meta = {
88
title: 'Tests/DataBinding',
@@ -385,4 +385,100 @@ export const ImagePropertyStory: StoryObj = {
385385
expect(canvas.getByTestId('current-image-url')).toBeTruthy();
386386
}, { timeout: 5000 });
387387
}
388+
};
389+
390+
391+
export const TodoListStory: StoryObj = {
392+
name: 'Todo List Property',
393+
render: () => <TodoListTest src="db_list_test.riv" />,
394+
play: async ({ canvasElement }) => {
395+
const canvas = within(canvasElement);
396+
397+
// Wait for the Rive file to load
398+
await waitFor(() => {
399+
expect(canvas.getByTestId('list-length')).toBeTruthy();
400+
}, { timeout: 3000 });
401+
402+
const initialLengthText = canvas.getByTestId('list-length').textContent;
403+
const initialCount = parseInt(initialLengthText?.match(/Items: (\d+)/)?.[1] || '0');
404+
405+
// Test 1: addInstance - Add item to end
406+
const addButton = canvas.getByTestId('add-item-button');
407+
await userEvent.click(addButton);
408+
409+
await waitFor(() => {
410+
expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${initialCount + 1}`);
411+
});
412+
413+
// Test 2: addInstanceAt - Add item at specific index (if we have items)
414+
if (initialCount > 0) {
415+
const addAtButton = canvas.getByTestId('add-item-at-button');
416+
await userEvent.click(addAtButton);
417+
418+
await waitFor(() => {
419+
expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${initialCount + 2}`);
420+
});
421+
}
422+
423+
// Test 3: getInstanceAt - Interact with specific items
424+
const currentCount = initialCount + (initialCount > 0 ? 2 : 1);
425+
if (currentCount > 0) {
426+
await waitFor(() => {
427+
expect(canvas.getByTestId('todo-item-0')).toBeTruthy();
428+
});
429+
430+
// Edit the first item
431+
const todoText = canvas.getByTestId('todo-text-0');
432+
await userEvent.clear(todoText);
433+
434+
// Wait for the input to be cleared to avoid issues with autocomplete
435+
await waitFor(() => {
436+
expect((todoText as HTMLInputElement).value).toBe('');
437+
}, { timeout: 2000 });
438+
439+
await userEvent.click(todoText);
440+
await userEvent.paste('Test Item');
441+
442+
await waitFor(() => {
443+
expect(canvas.getByTestId('todo-text-value-0').textContent).toContain('Test Item');
444+
}, { timeout: 3000 });
445+
446+
}
447+
448+
// Test 4: swap - Swap first two items
449+
if (currentCount >= 2) {
450+
const firstText = canvas.getByTestId<HTMLInputElement>('todo-text-0').value;
451+
const secondText = canvas.getByTestId<HTMLInputElement>('todo-text-1').value;
452+
453+
const swapButton = canvas.getByTestId('swap-button');
454+
await userEvent.click(swapButton);
455+
456+
await waitFor(() => {
457+
expect(canvas.getByTestId('todo-text-0')).toHaveValue(secondText);
458+
expect(canvas.getByTestId('todo-text-1')).toHaveValue(firstText);
459+
}, { timeout: 3000 });
460+
}
461+
462+
// Test 5: removeInstance - Remove by instance reference
463+
if (currentCount > 0) {
464+
const removeInstanceButton = canvas.getByTestId('remove-instance-button');
465+
await userEvent.click(removeInstanceButton);
466+
467+
await waitFor(() => {
468+
expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${currentCount - 1}`);
469+
}, { timeout: 3000 });
470+
}
471+
472+
// Test 6: removeInstanceAt - Remove by index
473+
const afterRemoveInstance = currentCount > 0 ? currentCount - 1 : 0;
474+
if (afterRemoveInstance > 0) {
475+
const removeIndexButton = canvas.getByTestId('remove-index-button');
476+
await userEvent.click(removeIndexButton);
477+
478+
await waitFor(() => {
479+
expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${afterRemoveInstance - 1}`);
480+
}, { timeout: 3000 });
481+
}
482+
483+
}
388484
};

examples/src/components/DataBindingTests.tsx

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import Rive, {
1111
useViewModelInstanceColor,
1212
useViewModelInstanceTrigger,
1313
useViewModelInstanceImage,
14-
decodeImage
14+
decodeImage,
15+
ViewModelInstance,
16+
useViewModelInstanceList
1517
} from '@rive-app/react-webgl2';
1618

1719

@@ -610,4 +612,178 @@ export const ImagePropertyTest = ({ src }: { src: string }) => {
610612
)}
611613
</div>
612614
);
615+
};
616+
617+
// List Property Test
618+
619+
const TodoItemComponent = ({
620+
index,
621+
todoItem
622+
}: {
623+
index: number;
624+
todoItem: ViewModelInstance | null;
625+
}) => {
626+
const { value: text, setValue: setText } = useViewModelInstanceString('text', todoItem);
627+
const { value: isDone, setValue: setIsDone } = useViewModelInstanceBoolean('isDone', todoItem);
628+
629+
if (!todoItem) {
630+
return <div data-testid={`todo-item-${index}`}>Item not found</div>;
631+
}
632+
633+
return (
634+
<div data-testid={`todo-item-${index}`} style={{
635+
display: 'flex',
636+
alignItems: 'center',
637+
gap: '10px',
638+
padding: '8px',
639+
border: '1px solid #ccc',
640+
marginBottom: '4px'
641+
}}>
642+
<input
643+
data-testid={`todo-checkbox-${index}`}
644+
type="checkbox"
645+
checked={isDone ?? false}
646+
onChange={(e) => setIsDone(e.target.checked)}
647+
/>
648+
<input
649+
data-testid={`todo-text-${index}`}
650+
type="text"
651+
value={text || ''}
652+
onChange={(e) => setText(e.target.value)}
653+
style={{ flex: 1 }}
654+
/>
655+
<div data-testid={`todo-text-value-${index}`} style={{ fontSize: '12px', color: '#666' }}>
656+
Text: {text}
657+
</div>
658+
<div data-testid={`todo-done-value-${index}`} style={{ fontSize: '12px', color: '#666' }}>
659+
Done: {isDone ? 'true' : 'false'}
660+
</div>
661+
</div>
662+
);
663+
};
664+
665+
export const TodoListTest = ({ src }: { src: string }) => {
666+
const { rive, RiveComponent } = useRive({
667+
src,
668+
autoplay: true,
669+
artboard: "Artboard",
670+
autoBind: false,
671+
stateMachines: "State Machine 1",
672+
});
673+
674+
const viewModel = useViewModel(rive, { name: 'TodoList' });
675+
const viewModelInstance = useViewModelInstance(viewModel, { rive });
676+
677+
const {
678+
length,
679+
addInstance,
680+
addInstanceAt,
681+
removeInstance,
682+
removeInstanceAt,
683+
getInstanceAt,
684+
swap
685+
} = useViewModelInstanceList('items', viewModelInstance);
686+
687+
const handleAddItem = () => {
688+
const todoItemViewModel = rive?.viewModelByName?.('TodoItem');
689+
if (todoItemViewModel) {
690+
const newTodoItem = todoItemViewModel.instance?.();
691+
if (newTodoItem) {
692+
addInstance(newTodoItem);
693+
}
694+
}
695+
};
696+
697+
const handleAddItemAt = () => {
698+
const todoItemViewModel = rive?.viewModelByName?.('TodoItem');
699+
if (todoItemViewModel && length > 0) {
700+
const newTodoItem = todoItemViewModel.instance?.();
701+
if (newTodoItem) {
702+
addInstanceAt(newTodoItem, 1);
703+
}
704+
}
705+
};
706+
707+
const handleRemoveFirstInstance = () => {
708+
const firstInstance = getInstanceAt(0);
709+
if (firstInstance) {
710+
removeInstance(firstInstance);
711+
}
712+
};
713+
714+
const handleRemoveFirstByIndex = () => {
715+
if (length > 0) {
716+
removeInstanceAt(0);
717+
}
718+
};
719+
720+
const handleSwapItems = () => {
721+
if (length >= 2) {
722+
swap(0, 1);
723+
}
724+
};
725+
726+
return (
727+
<div>
728+
<RiveComponent style={{ width: '400px', height: '400px' }} />
729+
{rive === null ? (
730+
<div data-testid="loading-text">Loading…</div>
731+
) : (
732+
<div>
733+
<div data-testid="list-length">Items: {length}</div>
734+
735+
<div style={{ marginBottom: '10px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
736+
<button
737+
data-testid="add-item-button"
738+
onClick={handleAddItem}
739+
>
740+
Add Item (End)
741+
</button>
742+
743+
<button
744+
data-testid="add-item-at-button"
745+
onClick={handleAddItemAt}
746+
disabled={length === 0}
747+
>
748+
Add Item at Index 1
749+
</button>
750+
751+
<button
752+
data-testid="remove-instance-button"
753+
onClick={handleRemoveFirstInstance}
754+
disabled={length === 0}
755+
>
756+
Remove First (by Instance)
757+
</button>
758+
759+
<button
760+
data-testid="remove-index-button"
761+
onClick={handleRemoveFirstByIndex}
762+
disabled={length === 0}
763+
>
764+
Remove First (by Index)
765+
</button>
766+
767+
<button
768+
data-testid="swap-button"
769+
onClick={handleSwapItems}
770+
disabled={length < 2}
771+
>
772+
Swap First Two
773+
</button>
774+
</div>
775+
776+
<div data-testid="todo-items">
777+
{Array.from({ length }, (_, index) => (
778+
<TodoItemComponent
779+
key={index}
780+
index={index}
781+
todoItem={getInstanceAt(index)}
782+
/>
783+
))}
784+
</div>
785+
</div>
786+
)}
787+
</div>
788+
);
613789
};

src/hooks/useViewModelInstanceList.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback } from 'react';
1+
import { useCallback, useState } from 'react';
22
import { ViewModelInstance, ViewModelInstanceList } from '@rive-app/canvas';
33
import { UseViewModelInstanceListResult } from '../types';
44
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
@@ -15,13 +15,22 @@ export default function useViewModelInstanceList(
1515
viewModelInstance?: ViewModelInstance | null
1616
): UseViewModelInstanceListResult {
1717

18+
// We track revision to trigger re-renders on list manipulation (e.g. addInstance, removeInstance, etc).
19+
// This is mostly important for things like the swap function which wouldn't trigger a re-render otherwise.
20+
// It also accounts for changes that happen within the Rive file itself rather than through the hook.
21+
const [_revision, setRevision] = useState(0);
22+
1823
const result = useViewModelInstanceProperty<ViewModelInstanceList, number, Omit<UseViewModelInstanceListResult, 'length'>>(
1924
path,
2025
viewModelInstance,
2126
{
2227
getProperty: useCallback((vm, p) => vm.list(p), []),
2328
getValue: useCallback((prop) => prop.length, []),
24-
defaultValue: 0,
29+
defaultValue: null,
30+
onPropertyEvent: () => {
31+
// This fires when the list changes in Rive
32+
setRevision(prev => prev + 1);
33+
},
2534
buildPropertyOperations: useCallback((safePropertyAccess) => ({
2635
addInstance: (instance: ViewModelInstance) => {
2736
safePropertyAccess(prop => prop.addInstance(instance));

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import useViewModelInstanceColor from './hooks/useViewModelInstanceColor';
1010
import useViewModelInstanceEnum from './hooks/useViewModelInstanceEnum';
1111
import useViewModelInstanceTrigger from './hooks/useViewModelInstanceTrigger';
1212
import useViewModelInstanceImage from './hooks/useViewModelInstanceImage';
13+
import useViewModelInstanceList from './hooks/useViewModelInstanceList';
1314
import useResizeCanvas from './hooks/useResizeCanvas';
1415
import useRiveFile from './hooks/useRiveFile';
1516

@@ -28,6 +29,7 @@ export {
2829
useViewModelInstanceEnum,
2930
useViewModelInstanceTrigger,
3031
useViewModelInstanceImage,
32+
useViewModelInstanceList,
3133
RiveProps,
3234
};
3335
export {

0 commit comments

Comments
 (0)