|
| 1 | +import { describe, expect, it } from 'vitest' |
| 2 | +import { saveDiscardActions } from '@/components/settings/save-discard-actions' |
| 3 | +import type { SettingsAction } from '@/components/settings/settings-header' |
| 4 | +import { orderHeaderActions } from '@/components/settings/settings-header' |
| 5 | + |
| 6 | +const noop = () => {} |
| 7 | + |
| 8 | +/** Labels in the order the header renders them. */ |
| 9 | +function rendered(actions: SettingsAction[]): string[] { |
| 10 | + return orderHeaderActions(actions).map(({ action }) => action.text) |
| 11 | +} |
| 12 | + |
| 13 | +const save = (dirty: boolean) => |
| 14 | + saveDiscardActions({ dirty, saving: false, onSave: noop, onDiscard: noop }) |
| 15 | + |
| 16 | +describe('orderHeaderActions', () => { |
| 17 | + it('puts Delete before Discard and Save no matter how the caller ordered them', () => { |
| 18 | + // The natural way to write this array — Save/Discard first, then Delete — |
| 19 | + // is what every settings detail page did, and it rendered Delete to the |
| 20 | + // right of the primary chip. |
| 21 | + const actions: SettingsAction[] = [ |
| 22 | + ...save(true), |
| 23 | + { id: 'delete', text: 'Delete', onSelect: noop }, |
| 24 | + ] |
| 25 | + |
| 26 | + expect(rendered(actions)).toEqual(['Delete', 'Discard', 'Save']) |
| 27 | + }) |
| 28 | + |
| 29 | + it('matches the skills detail header: secondary actions, then Delete, then Save', () => { |
| 30 | + const actions: SettingsAction[] = [ |
| 31 | + { text: 'Share', onSelect: noop }, |
| 32 | + { id: 'delete', text: 'Delete', onSelect: noop }, |
| 33 | + ...save(true), |
| 34 | + ] |
| 35 | + |
| 36 | + expect(rendered(actions)).toEqual(['Share', 'Delete', 'Discard', 'Save']) |
| 37 | + }) |
| 38 | + |
| 39 | + it('keeps Save right-most when there is nothing to discard', () => { |
| 40 | + const actions: SettingsAction[] = [ |
| 41 | + ...save(false), |
| 42 | + { id: 'delete', text: 'Delete', onSelect: noop }, |
| 43 | + ] |
| 44 | + |
| 45 | + expect(rendered(actions)).toEqual(['Delete', 'Save']) |
| 46 | + }) |
| 47 | + |
| 48 | + it('sends any primary action to the end, not just Save', () => { |
| 49 | + // Workflow MCP servers: Add workflows is the primary, Delete must precede it. |
| 50 | + const actions: SettingsAction[] = [ |
| 51 | + { text: 'Edit server', onSelect: noop }, |
| 52 | + { text: 'Add workflows', variant: 'primary', onSelect: noop }, |
| 53 | + { id: 'delete', text: 'Delete', onSelect: noop }, |
| 54 | + ] |
| 55 | + |
| 56 | + expect(rendered(actions)).toEqual(['Edit server', 'Delete', 'Add workflows']) |
| 57 | + }) |
| 58 | + |
| 59 | + it('preserves caller order within a band', () => { |
| 60 | + const actions: SettingsAction[] = [ |
| 61 | + { text: 'Refresh', onSelect: noop }, |
| 62 | + { text: 'Edit', onSelect: noop }, |
| 63 | + { id: 'delete', text: 'Delete', onSelect: noop }, |
| 64 | + ] |
| 65 | + |
| 66 | + expect(rendered(actions)).toEqual(['Refresh', 'Edit', 'Delete']) |
| 67 | + }) |
| 68 | + |
| 69 | + it('leaves a destructive bulk action left of the primary', () => { |
| 70 | + // Passwords: `Delete all` is destructive but must not outrank `Import`. |
| 71 | + // This is what keeps a red chip from becoming the right-most control. |
| 72 | + const actions: SettingsAction[] = [ |
| 73 | + { text: 'Delete all', variant: 'destructive', onSelect: noop }, |
| 74 | + { text: 'Import', variant: 'primary', onSelect: noop }, |
| 75 | + ] |
| 76 | + |
| 77 | + expect(rendered(actions)).toEqual(['Delete all', 'Import']) |
| 78 | + }) |
| 79 | + |
| 80 | + it('ranks a destructive action alongside secondary ones, not after Discard', () => { |
| 81 | + const actions: SettingsAction[] = [ |
| 82 | + ...save(true), |
| 83 | + { text: 'Sign out all members', variant: 'destructive', onSelect: noop }, |
| 84 | + ] |
| 85 | + |
| 86 | + expect(rendered(actions)).toEqual(['Sign out all members', 'Discard', 'Save']) |
| 87 | + }) |
| 88 | + |
| 89 | + it('treats primary as the stronger signal when an action is both', () => { |
| 90 | + const actions: SettingsAction[] = [ |
| 91 | + { text: 'Other', onSelect: noop }, |
| 92 | + { id: 'discard', text: 'Odd', variant: 'primary', onSelect: noop }, |
| 93 | + ] |
| 94 | + |
| 95 | + expect(rendered(actions)).toEqual(['Other', 'Odd']) |
| 96 | + }) |
| 97 | + |
| 98 | + it('carries each action original index so ref-routed handlers stay bound', () => { |
| 99 | + const actions: SettingsAction[] = [ |
| 100 | + ...save(true), // indices 0 (Discard), 1 (Save) |
| 101 | + { id: 'delete', text: 'Delete', onSelect: noop }, // index 2 |
| 102 | + ] |
| 103 | + |
| 104 | + expect(orderHeaderActions(actions).map(({ action, index }) => [action.text, index])).toEqual([ |
| 105 | + ['Delete', 2], |
| 106 | + ['Discard', 0], |
| 107 | + ['Save', 1], |
| 108 | + ]) |
| 109 | + }) |
| 110 | + |
| 111 | + it('tolerates an absent or empty action list', () => { |
| 112 | + expect(orderHeaderActions(undefined)).toEqual([]) |
| 113 | + expect(orderHeaderActions([])).toEqual([]) |
| 114 | + }) |
| 115 | + |
| 116 | + it('does not mutate the caller array', () => { |
| 117 | + // The shell sorts a prop read off a live ref; reordering it in place would |
| 118 | + // renumber the indices the handlers are routed through. |
| 119 | + const actions: SettingsAction[] = [ |
| 120 | + ...save(true), |
| 121 | + { id: 'delete', text: 'Delete', onSelect: noop }, |
| 122 | + ] |
| 123 | + const before = actions.map((a) => a.text) |
| 124 | + |
| 125 | + orderHeaderActions(actions) |
| 126 | + |
| 127 | + expect(actions.map((a) => a.text)).toEqual(before) |
| 128 | + }) |
| 129 | +}) |
0 commit comments