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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const table = useSpreadSheetTable<Row>({
initialData, // Row[] - initial row data
rowKey, // keyof Row - unique identifier field
onChange, // (changes: ChangeInfo<Row>[]) => void
onRowChangeCommitted, // (commit: RowChangeCommit<Row>) => void
onReorder, // (newData: Row[]) => void
validate, // (row: Row, key: string, value: unknown) => ValidationResult | undefined
frozenColumns, // number - columns to freeze from the left
Expand Down
37 changes: 37 additions & 0 deletions apps/docs/e2e/row-change.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,41 @@ test.describe('行変更の確定前処理', () => {
await expect(getCell(page, 0, 2)).toHaveText('10:00')
})

test('編集の確定内容が補正後の値を含めて通知される', async ({ page }) => {
await goToRowChangeDemo(page)
const demo = getDemoContainer(page)

await getCell(page, 0, 1).dblclick()
const input = demo.locator('input[type="time"]').first()
await input.fill('10:00')
await input.press('Enter')

await expect(page.getByTestId('committed-message')).toHaveText(
'edit: #0 startTime 09:00→10:00, endTime 10:00→11:00',
)
await expect(page.getByTestId('commit-count')).toHaveText('1')
})

test('ペーストは1操作としてまとめて通知される', async ({ page }) => {
await goToRowChangeDemo(page)
const mod = modKey(page)

await page.evaluate(() => navigator.clipboard.writeText('10:00\t11:00\n13:00\t14:00'))
await clickCell(page, 0, 1)
await page.keyboard.press(`${mod}+KeyV`)

await expect(page.getByTestId('committed-message')).toHaveText(
'paste: #0 startTime 09:00→10:00, endTime 10:00→11:00 / #1 startTime 11:00→13:00, endTime 12:00→14:00',
)
// 2行分の変更でも通知は1回のみ
await expect(page.getByTestId('commit-count')).toHaveText('1')

await expect(getCell(page, 0, 1)).toHaveText('10:00')
await expect(getCell(page, 0, 2)).toHaveText('11:00')
await expect(getCell(page, 1, 1)).toHaveText('13:00')
await expect(getCell(page, 1, 2)).toHaveText('14:00')
})

test('拒否された編集は反映されず、拒否理由が通知される', async ({ page }) => {
await goToRowChangeDemo(page)
const demo = getDemoContainer(page)
Expand All @@ -36,6 +71,8 @@ test.describe('行変更の確定前処理', () => {

await expect(getCell(page, 0, 2)).toHaveText('10:00')
await expect(page.getByTestId('rejected-message')).toHaveText('終了は開始より後にしてください')
// 拒否された操作は確定通知されない
await expect(page.getByTestId('commit-count')).toHaveText('0')
})

test('クリアはsourceがclearとして拒否される', async ({ page }) => {
Expand Down
23 changes: 21 additions & 2 deletions apps/docs/src/components/RowChangeDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,27 @@ const processRowChange: ProcessRowChange<Shift> = (candidate, previous, context)

export function RowChangeDemo() {
const [rejected, setRejected] = useState<string | null>(null)
const [committed, setCommitted] = useState<string | null>(null)
const [commitCount, setCommitCount] = useState(0)

const table = useSpreadSheetTable<Shift>({
columns,
initialData: shiftData,
rowKey: 'id',
processRowChange,
onRowChangeRejected: (errors) => {
setRejected(errors.map((error) => error.result.message).join(' / '))
onRowChangeCommitted: (commit) => {
const rows = commit.rows.map((row) => {
const changes = row.changes
.map((change) => `${String(change.key)} ${change.previousValue}→${change.newValue}`)
.join(', ')
return `#${row.rowIndex} ${changes}`
})
setCommitted(`${commit.source}: ${rows.join(' / ')}`)
setCommitCount((count) => count + 1)
},
onRowChangeRejected: (rejection) => {
setRejected(rejection.errors.map((error) => error.result.message).join(' / '))
setCommitted(null)
},
onChange: () => setRejected(null),
})
Expand All @@ -110,6 +123,12 @@ export function RowChangeDemo() {
<span className="demo-status" data-testid="rejected-message" style={{ color: '#e53935' }}>
{rejected ?? ''}
</span>
<span className="demo-status" data-testid="committed-message">
{committed ?? ''}
</span>
<span className="demo-status" data-testid="commit-count">
{commitCount}
</span>
</div>
<SpreadSheetTable table={table} height={240} />
</div>
Expand Down
18 changes: 14 additions & 4 deletions apps/docs/src/content/docs/api/use-spread-sheet-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ const table = useSpreadSheetTable<Row>({
| `columns` | `ColumnDef<T>[]` | (必須) | カラム定義の配列 |
| `initialData` | `T[]` | (必須) | 初期データ |
| `rowKey` | `keyof T` | (必須) | 行のユニークキー |
| `onChange` | `(rows: T[]) => void` | - | データ変更時のコールバック |
| `onChange` | `(rows: ChangeInfo<T>[]) => void` | - | 編集基準から変更された行を通知 |
| `onRowChangeCommitted` | `(commit: RowChangeCommit<T>) => void` | - | 今回の操作で確定した変更前後の行を通知 |
Comment thread
RyoSogawa marked this conversation as resolved.
| `onReorder` | `(rows: T[]) => void` | - | 行並び替え時のコールバック |
| `validate` | `(value, row, key) => ValidationResult \| null` | - | カスタムバリデーション |
| `onValidationError` | `(errors) => void` | - | バリデーションエラーのコールバック |
| `processRowChange` | `(candidate, previous, context) => ProcessRowChangeResult<T>` | - | 行変更を確定前に補正または拒否 |
| `onRowChangeRejected` | `(errors) => void` | - | 行変更が拒否されたときのコールバック |
| `onRowChangeRejected` | `(rejection: RowChangeRejection<T>) => void` | - | 拒否された操作と行を通知 |
| `readOnly` | `boolean` | `false` | テーブル全体を読み取り専用に |
| `sortable` | `boolean` | `true` | ソート有効/無効 |
| `filterable` | `boolean` | `true` | フィルター有効/無効 |
Expand Down Expand Up @@ -94,8 +95,13 @@ const table = useSpreadSheetTable<ShiftRow>({
row: nextRow,
}
},
onRowChangeRejected: (errors) => {
console.log('Rejected row change:', errors)
onRowChangeCommitted: (commit) => {
for (const { previousRow, row } of commit.rows) {
saveChangedRow(previousRow, row)
}
},
onRowChangeRejected: (rejection) => {
console.log('Rejected row change:', rejection.rows)
},
})
```
Expand All @@ -105,3 +111,7 @@ const table = useSpreadSheetTable<ShiftRow>({
補正した値は元の編集と同じ操作として記録されるため、1回のUndoでまとめて元に戻ります。
複数行のペーストでは、1行でも拒否されるとペースト全体を反映しません。
範囲を選択したDeleteとCutも同じ扱いで、1行でも拒否されると範囲全体をクリアしません。

`onChange`は編集基準からの累積変更を通知します。
今回の操作だけをAPIへ送る場合は、`onRowChangeCommitted`の`previousRow`と`row`を使用してください。
渡された行はテーブル内のスナップショットとして読み取り専用で扱います。
1 change: 1 addition & 0 deletions apps/docs/src/content/docs/playground/row-change.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RowChangeDemo } from '../../../components/RowChangeDemo'
- 終了時刻が開始時刻以前になる変更は拒否されます
- 担当者と開始の列は Delete / Cut でクリアできません (`context.source` が `clear` の場合の分岐)
- 複数行のペーストは、1行でも拒否されると全体が反映されません
- 確定した変更は `onRowChangeCommitted` で操作単位に通知されます (補正後の値を含みます)

<div class="not-content">
<RowChangeDemo client:only="react" />
Expand Down
97 changes: 97 additions & 0 deletions openspec/changes/add-change-transaction-context/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
## Context

テーブルは非制御コンポーネントとして行データを内部に保持する。

現在の`onChange`第1引数は、編集基準から変更された行を返す。この情報は保存対象全体の取得には適しているが、今回の操作でどの状態からどの状態へ変わったかは表さない。

利用側が操作単位のAPI更新を作る場合、通知された行と比較するための行データをテーブルとは別に保持する必要がある。

## Goals / Non-Goals

### Goals

- 利用側が行データを二重管理せず、操作直前と確定後の行を取得できるようにする。
- 補正された複数項目を含む最終行を、1操作の結果として通知する。
- 拒否された変更候補と元の行を、行番号から引き直さず取得できるようにする。
- 既存の`onChange`利用側との互換性を保つ。

### Non-Goals

- APIリクエストや業務固有の更新イベントをライブラリ内で生成すること。
- 非同期の行検証を追加すること。
- 外部制御コンポーネントへ変更すること。
- 累積変更を返す`getChangedRows()`の意味を変更すること。

## Decisions

### 操作単位の確定通知を独立したコールバックとして追加する

`onChange`は編集基準からの累積変更を通知する既存APIとして維持する。

操作単位の確定結果は、新しい`onRowChangeCommitted`で通知する。

既存コールバックへ引数を追加すると、引数を厳密に検証する利用側のテストへ影響する可能性がある。独立したコールバックにすることで、既存`onChange`の型と実行時の呼び出しを変更しない。

```ts
type CommittedRowChange<T> = {
rowIndex: number
previousRow: T
row: T
changes: ReadonlyArray<CellChange<T>>
}

type RowChangeCommit<T> = {
source: 'edit' | 'paste'
rows: ReadonlyArray<CommittedRowChange<T>>
}

type RejectedRowChange<T> = {
rowIndex: number
previousRow: T
candidateRow: T
issues: ReadonlyArray<RowChangeIssue<T>>
}

type RowChangeRejection<T> = {
source: 'edit' | 'paste'
errors: ReadonlyArray<CellValidationError>
rows: ReadonlyArray<RejectedRowChange<T>>
}
```

行オブジェクトはテーブルが保持するスナップショットとして渡し、利用側は読み取り専用として扱う。

### 変更トランザクションは操作直前のStoreから作る

編集確定直前に`store.getRows()`を取得し、確定後の行と比較してトランザクションを組み立てる。

これにより、`markAsSaved()`や編集基準からの累積変更に依存せず、今回の操作だけを表現できる。

`processRowChange`が補正した項目も、確定前後で値が異なる場合は`changes`へ含める。

### 拒否トランザクションは行変更準備中に保持する

`prepareCellUpdates`は変更前の行、入力候補の行、`processRowChange`が返した拒否理由をすでに参照している。

拒否時にこの情報を破棄せず、セルエラーとともに`useSpreadSheetTable`へ返す。

複数行ペーストでは拒否された行をまとめ、ペースト全体が確定されなかったことを1回のコールバックで通知する。

## Risks / Trade-offs

- 行オブジェクトをコールバックへ追加するため、通知データ量が増える。
対象は1操作で影響を受けた行だけに限定する。
- `onChange`の累積変更と`onRowChangeCommitted`の操作変更は意味が異なる。
コールバック名、型名、APIドキュメント、例で区別を明記する。
- 行オブジェクトはStoreのスナップショットであり、利用側が変更すると混乱を招く。
公開型を`readonly`とし、ライブラリ内ではイミュータブルに扱う。

## Migration Plan

1. 公開型とテストを追加する。
2. 行変更準備処理から拒否コンテキストを返す。
3. 確定前後の行から変更トランザクションを作る。
4. 独立した確定コールバックと拒否コールバックから通知する。
5. ドキュメントと利用例を更新する。

既存の`onChange`を変更しないため、利用側の一括移行は不要である。
22 changes: 22 additions & 0 deletions openspec/changes/add-change-transaction-context/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Change: 変更操作の行コンテキストを通知する

## Why

`onChange`は編集基準からの累積変更行だけを通知するため、利用側が操作直前と確定後の行を比較するには、テーブル内部と同じ行データを別途保持する必要がある。

`onRowChangeRejected`も行番号とセルエラーだけを通知するため、利用側は拒否された行の識別情報を自前の行データから引き直している。

テーブルがすでに保持している操作単位の行コンテキストを公開し、利用側による状態の二重管理を不要にする。

## What Changes

- `onRowChangeCommitted`を追加し、操作種別と確定前後の行を操作単位で通知する。
- `onRowChangeRejected`は、操作種別、セルエラー、変更前の行、変更候補の行、拒否理由を一つのオブジェクトで通知する。
- 単一セル編集と複数セルペーストのどちらも、1操作につき1トランザクションを通知する。
- 既存の`onChange`の型と呼び出し方は変更せず、既存利用側との互換性を保つ。

## Impact

- Affected specs: `table-api`
- Affected code: public types、行変更準備、`useSpreadSheetTable`、APIドキュメント
- Consumer impact: 既存の`onChange`利用側は変更不要。操作単位の前後行が必要な利用側だけ新しいコールバックを使用する。
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
## ADDED Requirements

### Requirement: Row Change Commit Notification

テーブルは、単一セル編集またはペーストによる変更を確定した際、`onRowChangeCommitted`へ今回の操作で確定した行変更を通知しなければならない(MUST)。

確定通知は操作種別、および影響を受けた各行の変更前の行、確定後の行、セル単位の変更詳細を含まなければならない(MUST)。

既存の`onChange`の型と呼び出し方を変更してはならない(MUST NOT)。

```typescript
type RowChangeCommit<T> = {
source: 'edit' | 'paste'
rows: ReadonlyArray<{
rowIndex: number
previousRow: T
row: T
changes: ReadonlyArray<{
key: keyof T
previousValue: T[keyof T]
newValue: T[keyof T]
}>
}>
}
```

#### Scenario: Single cell edit notification

- **WHEN** ユーザーが1つのセルを編集する
- **THEN** `onRowChangeCommitted`が1回だけ呼ばれる
- **AND** 操作種別`edit`と変更前後の行が渡される
- **AND** `changes`に今回変更したセルの変更前後の値が含まれる

#### Scenario: Normalized row notification

- **GIVEN** `processRowChange`が同じ行の別項目を補正する
- **WHEN** ユーザーがセル編集を確定する
- **THEN** 確定後の行に補正済みの値が含まれる
- **AND** `changes`に入力項目と補正項目が含まれる

#### Scenario: Bulk paste notification

- **WHEN** ユーザーが3行分のデータをペーストする
- **THEN** `onRowChangeCommitted`は1回だけ呼ばれる
- **AND** 操作種別は`paste`である
- **AND** 影響を受けた3行の変更前後の行が含まれる

#### Scenario: Existing onChange compatibility

- **GIVEN** 利用側が既存の`onChange`だけを指定している
- **WHEN** ユーザーがセルを編集する
- **THEN** `onChange`は従来と同じ1引数で呼ばれる

### Requirement: Row Change Rejection Context

`processRowChange`が行変更を拒否した場合、`onRowChangeRejected`は操作種別、セルエラー、および拒否された各行の変更前の行、変更候補の行、拒否理由を一つの拒否オブジェクトとして通知しなければならない(MUST)。

#### Scenario: Rejected single edit context

- **WHEN** `processRowChange`が単一セル編集を拒否する
- **THEN** `onRowChangeRejected`が1回だけ呼ばれる
- **AND** 操作種別`edit`、セルエラー、変更前の行、変更候補の行、拒否理由が渡される

#### Scenario: Rejected atomic paste context

- **WHEN** 複数行ペーストの一部を`processRowChange`が拒否する
- **THEN** ペースト全体は確定されない
- **AND** `onRowChangeRejected`は1回だけ呼ばれる
- **AND** 拒否された各行の変更前の行、変更候補の行、拒否理由が含まれる
14 changes: 14 additions & 0 deletions openspec/changes/add-change-transaction-context/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## 1. Implementation

- [x] 1.1 単一編集の変更トランザクションをテストで固定する。
- [x] 1.2 補正された複数項目と複数行ペーストのトランザクションをテストで固定する。
- [x] 1.3 拒否トランザクションへ変更前の行と候補行が含まれることをテストで固定する。
- [x] 1.4 公開型へ変更トランザクションと拒否トランザクションを追加する。
- [x] 1.5 `useSpreadSheetTable`から独立した確定コールバックと拒否コールバックを通知する。
- [x] 1.6 APIドキュメントと利用例を更新する。
- [x] 1.7 typecheck、lint、test、buildを実行する。

## 2. Consumer Verification

- [x] 2.1 利用側が行データの比較用コピーを保持せず、変更トランザクションからAPI更新を作れることを確認する。
- [x] 2.2 利用側が拒否された行を行番号から引き直さず、検証エラーへ変換できることを確認する。
4 changes: 4 additions & 0 deletions packages/core/src/core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ export type {
CellChange,
CellMeta,
ChangeInfo,
CommittedRowChange,
ProcessRowChange,
ProcessRowChangeResult,
RejectedRowChange,
RowChangeCommit,
RowChangeContext,
RowChangeIssue,
RowChangeRejection,
RowChangeSource,
SpreadSheetTableProps,
TableInstance,
Expand Down
Loading
Loading