Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/docs/src/content/docs/api/spread-sheet-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { SpreadSheetTable } from '@heynow-jp/react-spread-sheet-table'
| `readOnly` | `boolean` | `false` | テーブル全体を読み取り専用に |
| `cellMeta` | `(row: T, columnKey: keyof T, rowIndex: number) => CellMeta \| undefined` | - | セルごとの追加スタイル・ツールチップを返すコールバック |
| `autoWidth` | `boolean` | `false` | テーブル幅をカラム幅の合計に合わせる |
| `showValidationToast` | `boolean` | `true` | 編集拒否や入力形式エラーを組み込みトーストで表示する |

## ReactSpreadSheetTableProvider

Expand Down
19 changes: 19 additions & 0 deletions openspec/changes/add-validation-toast-control/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Change: バリデーショントーストの表示を制御できるようにする

## Why

利用側が独自のエラー表示を持つ場合、ライブラリ内蔵のトーストと同じエラーが重複して表示される。
バリデーションや拒否通知は維持したまま、内蔵トーストだけを非表示にできるようにする。

## What Changes

- `SpreadSheetTable`に`showValidationToast`プロパティを追加する。
- 既定値は`true`とし、既存の表示挙動を維持する。
- `false`の場合もバリデーション処理と`onRowChangeRejected`などの通知は継続する。
- 非表示中に発生したメッセージを保持せず、再表示時に古いエラーが現れないようにする。
- APIリファレンスを更新する。

## Impact

- Affected specs: `validation`
- Affected code: `SpreadSheetTable`の公開型と表示、Toast、コンポーネントテスト、ドキュメント
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## ADDED Requirements

### Requirement: Validation Toast Visibility Control

`SpreadSheetTable`は、バリデーション処理や利用側への通知を無効にせず、組み込みバリデーショントーストの表示だけを制御できなければならない(MUST)。

表示設定を省略した場合は、後方互換性のため組み込みトーストを表示しなければならない(MUST)。

#### Scenario: Show the built-in toast by default

- **GIVEN** `showValidationToast`を指定していない
- **WHEN** セル編集がバリデーションエラーで拒否される
- **THEN** 組み込みトーストにエラーメッセージが表示される

#### Scenario: Hide only the built-in toast

- **GIVEN** `showValidationToast={false}`を指定している
- **WHEN** セル編集がバリデーションエラーで拒否される
- **THEN** 組み込みトーストは表示されない
- **AND** `onRowChangeRejected`には拒否理由が通知される

#### Scenario: Do not show a stale error after enabling the toast

- **GIVEN** 組み込みトーストを非表示にしている間にバリデーションエラーが発生した
- **WHEN** `showValidationToast`を`true`へ変更する
- **THEN** 非表示中に発生したエラーメッセージは表示されない
8 changes: 8 additions & 0 deletions openspec/changes/add-validation-toast-control/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## 1. Implementation

- [x] 1.1 内蔵トーストの既定表示をコンポーネントテストで固定する。
- [x] 1.2 `showValidationToast`を公開型と`SpreadSheetTable`へ追加する。
- [x] 1.3 トースト非表示時も拒否通知が維持されることをテストする。
- [x] 1.4 非表示中のメッセージが再表示時に残らないようにする。
- [x] 1.5 APIリファレンスを更新する。
- [x] 1.6 typecheck、lint、test、buildを実行する。
103 changes: 103 additions & 0 deletions packages/core/src/components/SpreadSheetTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { TableInstance } from '../core/types'
import { useSpreadSheetTable } from '../hooks/use-spread-sheet-table'
import { SpreadSheetTable } from './SpreadSheetTable'

type TestRow = {
id: string
minutes: number
}

type InternalTableInstance = TableInstance<TestRow> & {
__handleCellChange: (
rowIndex: number,
columnKey: keyof TestRow,
value: TestRow[keyof TestRow],
) => void
}

vi.hoisted(() => {
class ResizeObserverStub {
disconnect() {}
observe() {}
unobserve() {}
}

vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})

afterAll(() => vi.unstubAllGlobals())

const renderRejectedEdit = (showValidationToast?: boolean) => {
const container = document.createElement('div')
const root = createRoot(container)
const onRowChangeRejected = vi.fn()
let table: InternalTableInstance | undefined

const Harness = ({ showToast }: { showToast?: boolean }) => {
table = useSpreadSheetTable<TestRow>({
columns: [{ type: 'number', key: 'minutes', header: 'Minutes' }],
initialData: [{ id: 'row-1', minutes: 30 }],
rowKey: 'id',
processRowChange: () => ({
status: 'rejected',
issues: [
{
columnKey: 'minutes',
result: { level: 'error', message: 'Minutes are invalid' },
},
],
}),
onRowChangeRejected,
}) as InternalTableInstance

return <SpreadSheetTable table={table} showValidationToast={showToast} />
}

act(() => root.render(<Harness showToast={showValidationToast} />))
act(() => table?.__handleCellChange(0, 'minutes', 1000))

return {
container,
onRowChangeRejected,
root,
rerender: (showToast?: boolean) => act(() => root.render(<Harness showToast={showToast} />)),
}
}

const unmount = (root: Root) => act(() => root.unmount())

describe('SpreadSheetTable validation toast', () => {
it('shows rejected edit errors by default', () => {
const { container, root } = renderRejectedEdit()

expect(container).toHaveTextContent('入力エラー')
expect(container).toHaveTextContent('Minutes are invalid')

unmount(root)
})

it('does not show rejected edit errors when the toast is disabled', () => {
const { container, onRowChangeRejected, root } = renderRejectedEdit(false)

expect(container).not.toHaveTextContent('入力エラー')
expect(container).not.toHaveTextContent('Minutes are invalid')
expect(onRowChangeRejected).toHaveBeenCalledOnce()

unmount(root)
})

it('does not retain errors that occurred while the toast was disabled', () => {
const { container, rerender, root } = renderRejectedEdit(false)

rerender(true)

expect(container).not.toHaveTextContent('入力エラー')
expect(container).not.toHaveTextContent('Minutes are invalid')

unmount(root)
})
})
4 changes: 3 additions & 1 deletion packages/core/src/components/SpreadSheetTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type SpreadSheetTableComponentProps<T> = {
readonly height?: number
readonly cellMeta?: CellMetaFn<T>
readonly autoWidth?: boolean
readonly showValidationToast?: boolean
}
const ROW_HEIGHT = 32

Expand Down Expand Up @@ -192,6 +193,7 @@ function SpreadSheetTableInner<T>({
height,
cellMeta,
autoWidth = false,
showValidationToast = true,
}: SpreadSheetTableComponentProps<T>) {
const store = (table as TableInstance<T> & { __store: TableStore<T> }).__store
const handleCellChange = (
Expand Down Expand Up @@ -655,7 +657,7 @@ function SpreadSheetTableInner<T>({
onBlur={handleBlur}
tabIndex={0}
>
<Toast store={store} />
<Toast store={store} visible={showValidationToast} />
<div
ref={virtualScroll.containerRef}
className={scrollStyles.scrollContainer}
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/components/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ const AUTO_DISMISS_MS = 5000

type ToastProps<T> = {
readonly store: TableStore<T>
readonly visible?: boolean
}

function ToastInner<T>({ store }: ToastProps<T>) {
function ToastInner<T>({ store, visible = true }: ToastProps<T>) {
useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)

const messages = store.getToastMessages()
Expand All @@ -17,18 +18,22 @@ function ToastInner<T>({ store }: ToastProps<T>) {

// biome-ignore lint/correctness/useExhaustiveDependencies: toastVersion resets the dismiss timer when a new toast arrives while one is already visible
useEffect(() => {
if (!visible) {
if (hasMessages) store.clearToast()
return
}
if (!hasMessages) return
const timer = setTimeout(() => {
store.clearToast()
}, AUTO_DISMISS_MS)
return () => clearTimeout(timer)
}, [hasMessages, toastVersion, store])
}, [hasMessages, toastVersion, store, visible])

const handleClose = useCallback(() => {
store.clearToast()
}, [store])

if (!hasMessages) return null
if (!visible || !hasMessages) return null

return (
<div className={styles.toastContainer}>
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core/types/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,5 @@ export type SpreadSheetTableProps<T> = {
readonly readOnly?: boolean
readonly cellMeta?: (row: T, columnKey: keyof T, rowIndex: number) => CellMeta | undefined
readonly autoWidth?: boolean
readonly showValidationToast?: boolean
}
Loading