設定画面 (#34) #397
@@ -0,0 +1,34 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Setting, type: :model do
|
||||||
|
it 'accepts the default typed user settings' do
|
||||||
|
setting = described_class.new({ user: create(:user) }.merge(described_class.defaults))
|
||||||
|
|
||||||
|
expect(setting).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires one settings row per user' do
|
||||||
|
user = create(:user)
|
||||||
|
described_class.create!({ user: }.merge(described_class.defaults))
|
||||||
|
|
||||||
|
duplicate = described_class.new({ user: }.merge(described_class.defaults))
|
||||||
|
|
||||||
|
expect(duplicate).not_to be_valid
|
||||||
|
expect(duplicate.errors[:user_id]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates enum-like settings columns' do
|
||||||
|
setting = described_class.new(
|
||||||
|
{ user: create(:user),
|
||||||
|
theme: 'neon',
|
||||||
|
auto_fetch_title: 'sometimes',
|
||||||
|
auto_fetch_thumbnail: 'sometimes',
|
||||||
|
wiki_editor_mode: 'sideways' })
|
||||||
|
|
||||||
|
expect(setting).not_to be_valid
|
||||||
|
expect(setting.errors[:theme]).to be_present
|
||||||
|
expect(setting.errors[:auto_fetch_title]).to be_present
|
||||||
|
expect(setting.errors[:auto_fetch_thumbnail]).to be_present
|
||||||
|
expect(setting.errors[:wiki_editor_mode]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe UserThemeSlot, type: :model do
|
||||||
|
it 'accepts a theme slot for one user, base theme, and slot number' do
|
||||||
|
slot = described_class.new(user: create(:user),
|
||||||
|
base_theme: 'light',
|
||||||
|
slot_no: 1,
|
||||||
|
tokens: { 'background' => '0 0% 100%' })
|
||||||
|
|
||||||
|
expect(slot).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires unique slot numbers per user and base theme' do
|
||||||
|
user = create(:user)
|
||||||
|
described_class.create!(user:,
|
||||||
|
base_theme: 'dark',
|
||||||
|
slot_no: 2,
|
||||||
|
tokens: { 'background' => '222.2 84% 4.9%' })
|
||||||
|
|
||||||
|
duplicate = described_class.new(user:,
|
||||||
|
base_theme: 'dark',
|
||||||
|
slot_no: 2,
|
||||||
|
tokens: { 'background' => '0 0% 100%' })
|
||||||
|
|
||||||
|
expect(duplicate).not_to be_valid
|
||||||
|
expect(duplicate.errors[:user_id]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates base theme, slot number, and token object shape' do
|
||||||
|
slot = described_class.new(user: create(:user),
|
||||||
|
base_theme: 'system',
|
||||||
|
slot_no: 4,
|
||||||
|
tokens: 'not-object')
|
||||||
|
|
||||||
|
expect(slot).not_to be_valid
|
||||||
|
expect(slot.errors[:base_theme]).to be_present
|
||||||
|
expect(slot.errors[:slot_no]).to be_present
|
||||||
|
expect(slot.errors[:tokens]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'user settings', type: :request do
|
||||||
|
describe 'GET /users/settings' do
|
||||||
|
it 'requires a current user' do
|
||||||
|
sign_out
|
||||||
|
|
||||||
|
get '/users/settings'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns defaults and creates a missing settings row' do
|
||||||
|
user = create(:user)
|
||||||
|
sign_in_as(user)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
get '/users/settings'
|
||||||
|
}.to change(Setting, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to eq(
|
||||||
|
'theme' => 'system',
|
||||||
|
'auto_fetch_title' => 'manual',
|
||||||
|
'auto_fetch_thumbnail' => 'manual',
|
||||||
|
'wiki_editor_mode' => 'split')
|
||||||
|
expect(user.reload.setting).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PATCH /users/settings' do
|
||||||
|
it 'requires a current user' do
|
||||||
|
sign_out
|
||||||
|
|
||||||
|
patch '/users/settings', params: { theme: 'dark' }, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates multiple typed settings at once' do
|
||||||
|
user = create(:user)
|
||||||
|
sign_in_as(user)
|
||||||
|
|
||||||
|
patch '/users/settings',
|
||||||
|
params: {
|
||||||
|
theme: 'dark',
|
||||||
|
auto_fetch_title: 'auto',
|
||||||
|
auto_fetch_thumbnail: 'off',
|
||||||
|
wiki_editor_mode: 'preview' },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to include(
|
||||||
|
'theme' => 'dark',
|
||||||
|
'auto_fetch_title' => 'auto',
|
||||||
|
'auto_fetch_thumbnail' => 'off',
|
||||||
|
'wiki_editor_mode' => 'preview')
|
||||||
|
expect(user.reload.setting).to have_attributes(
|
||||||
|
theme: 'dark',
|
||||||
|
auto_fetch_title: 'auto',
|
||||||
|
auto_fetch_thumbnail: 'off',
|
||||||
|
wiki_editor_mode: 'preview')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not treat Rails parameter wrapping as an editable setting' do
|
||||||
|
user = create(:user)
|
||||||
|
sign_in_as(user)
|
||||||
|
|
||||||
|
patch '/users/settings',
|
||||||
|
params: { theme: 'light', user_setting: { theme: 'dark' } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.reload.setting.theme).to eq('light')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns validation_error for type mismatches' do
|
||||||
|
sign_in_as(create(:user))
|
||||||
|
|
||||||
|
patch '/users/settings', params: { theme: 1 }, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json).to include('type' => 'validation_error')
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'theme' => ['値の型が不正です.'])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns validation_error for invalid enum values' do
|
||||||
|
sign_in_as(create(:user))
|
||||||
|
|
||||||
|
patch '/users/settings', params: { theme: 'neon' }, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json).to include('type' => 'validation_error')
|
||||||
|
expect(json.fetch('errors').fetch('theme')).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'user theme slots', type: :request do
|
||||||
|
describe 'GET /users/theme_slots' do
|
||||||
|
it 'requires a current user' do
|
||||||
|
sign_out
|
||||||
|
|
||||||
|
get '/users/theme_slots'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only the current user slots in stable order' do
|
||||||
|
user = create(:user)
|
||||||
|
other_user = create(:user)
|
||||||
|
sign_in_as(user)
|
||||||
|
UserThemeSlot.create!(user:, base_theme: 'light', slot_no: 2, tokens: { 'a' => 1 })
|
||||||
|
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 1, tokens: { 'b' => 2 })
|
||||||
|
UserThemeSlot.create!(user: other_user,
|
||||||
|
base_theme: 'dark',
|
||||||
|
slot_no: 3,
|
||||||
|
tokens: { 'c' => 3 })
|
||||||
|
|
||||||
|
get '/users/theme_slots'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { |slot| [slot.fetch('base_theme'), slot.fetch('slot_no')] }).to eq(
|
||||||
|
[['dark', 1], ['light', 2]])
|
||||||
|
expect(json.map { |slot| slot.fetch('tokens') }).to eq(
|
||||||
|
[{ 'b' => 2 }, { 'a' => 1 }])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PUT /users/theme_slots/:base_theme/:slot_no' do
|
||||||
|
it 'requires a current user' do
|
||||||
|
sign_out
|
||||||
|
|
||||||
|
put '/users/theme_slots/light/1',
|
||||||
|
params: { tokens: { background: '0 0% 100%' } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a theme slot' do
|
||||||
|
user = create(:user)
|
||||||
|
sign_in_as(user)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put '/users/theme_slots/light/1',
|
||||||
|
params: { tokens: { background: '0 0% 100%', tagColours: { nico: '#ffffff' } } },
|
||||||
|
as: :json
|
||||||
|
}.to change(UserThemeSlot, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to include('base_theme' => 'light', 'slot_no' => 1)
|
||||||
|
expect(json.fetch('tokens')).to include(
|
||||||
|
'background' => '0 0% 100%',
|
||||||
|
'tagColours' => { 'nico' => '#ffffff' })
|
||||||
|
expect(user.theme_slots.find_by!(base_theme: 'light', slot_no: 1).tokens).to include(
|
||||||
|
'background' => '0 0% 100%')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates an existing theme slot instead of creating a duplicate' do
|
||||||
|
user = create(:user)
|
||||||
|
sign_in_as(user)
|
||||||
|
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 2, tokens: { 'old' => true })
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put '/users/theme_slots/dark/2',
|
||||||
|
params: { tokens: { background: '222.2 84% 4.9%' } },
|
||||||
|
as: :json
|
||||||
|
}.not_to change(UserThemeSlot, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.theme_slots.find_by!(base_theme: 'dark', slot_no: 2).tokens).to eq(
|
||||||
|
'background' => '222.2 84% 4.9%')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects invalid path parameters and token shapes' do
|
||||||
|
sign_in_as(create(:user))
|
||||||
|
|
||||||
|
put '/users/theme_slots/system/4',
|
||||||
|
params: { tokens: 'not-object' },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'base_theme' => ['値が不正です.'])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects non-object tokens for valid slots' do
|
||||||
|
sign_in_as(create(:user))
|
||||||
|
|
||||||
|
put '/users/theme_slots/light/1',
|
||||||
|
params: { tokens: 'not-object' },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'tokens' => ['JSON object で指定してください.'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -20,4 +20,16 @@ describe ('TabGroup', () => {
|
|||||||
expect (screen.getByText ('Alpha')).toBeInTheDocument ()
|
expect (screen.getByText ('Alpha')).toBeInTheDocument ()
|
||||||
expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('renders tabs as buttons and shows the right addon', () => {
|
||||||
|
render (
|
||||||
|
<TabGroup rightAddon={<span>件数 20</span>}>
|
||||||
|
<Tab name="広場">Plaza</Tab>
|
||||||
|
</TabGroup>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect (screen.getByRole ('button', { name: '広場' })).toBeInTheDocument ()
|
||||||
|
expect (screen.queryByRole ('link', { name: '広場' })).not.toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('件数 20')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { DEFAULT_KEY_BINDINGS,
|
||||||
|
formatKeyBinding,
|
||||||
|
getConflictingShortcutActionIds,
|
||||||
|
isEditableEventTarget,
|
||||||
|
keyBindingFromKeyboardEvent,
|
||||||
|
resolveShortcutScope,
|
||||||
|
SHORTCUT_DEFINITIONS_BY_ID } from '@/lib/keyboardShortcuts'
|
||||||
|
|
||||||
|
describe ('keyboardShortcuts', () => {
|
||||||
|
it ('defines the default key bindings used by the settings screen', () => {
|
||||||
|
expect (SHORTCUT_DEFINITIONS_BY_ID['global.openShortcutHelp']).toMatchObject ({
|
||||||
|
label: 'ショートカット一覧を開く',
|
||||||
|
scope: 'global',
|
||||||
|
defaultBinding: { key: '?', shift: true } })
|
||||||
|
expect (DEFAULT_KEY_BINDINGS['global.focusSearch']).toEqual ({ key: '/' })
|
||||||
|
expect (DEFAULT_KEY_BINDINGS['navigation.posts']).toEqual ({ key: 'p' })
|
||||||
|
expect (DEFAULT_KEY_BINDINGS['pagination.next']).toEqual ({ key: 'ArrowRight' })
|
||||||
|
expect (DEFAULT_KEY_BINDINGS['settings.theme']).toEqual ({ key: '3' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('formats key bindings for display', () => {
|
||||||
|
expect (formatKeyBinding ({ key: '?', shift: true })).toBe ('Shift + ?')
|
||||||
|
expect (formatKeyBinding ({ key: '/' })).toBe ('/')
|
||||||
|
expect (formatKeyBinding ({ key: 'p' })).toBe ('P')
|
||||||
|
expect (formatKeyBinding ({ key: 'ArrowRight' })).toBe ('ArrowRight')
|
||||||
|
expect (formatKeyBinding (null)).toBe ('未割当')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('normalises keyboard events without assigning space by default', () => {
|
||||||
|
const event = { key: 'P',
|
||||||
|
ctrlKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false } as KeyboardEvent
|
||||||
|
|
||||||
|
expect (keyBindingFromKeyboardEvent (event)).toEqual ({ key: 'p' })
|
||||||
|
expect (DEFAULT_KEY_BINDINGS['pagination.next']).not.toEqual ({ key: ' ' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('detects conflicting action bindings', () => {
|
||||||
|
const conflicts = getConflictingShortcutActionIds ({
|
||||||
|
...DEFAULT_KEY_BINDINGS,
|
||||||
|
'navigation.posts': { key: 'p' },
|
||||||
|
'navigation.tags': { key: 'p' } })
|
||||||
|
|
||||||
|
expect (conflicts.has ('navigation.posts')).toBe (true)
|
||||||
|
expect (conflicts.has ('navigation.tags')).toBe (true)
|
||||||
|
expect (conflicts.has ('navigation.wiki')).toBe (false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('resolves route scopes and editable event targets', () => {
|
||||||
|
const input = document.createElement ('input')
|
||||||
|
const div = document.createElement ('div')
|
||||||
|
div.setAttribute ('role', 'textbox')
|
||||||
|
|
||||||
|
expect (resolveShortcutScope ('/posts/search')).toBe ('postSearch')
|
||||||
|
expect (resolveShortcutScope ('/users/settings')).toBe ('settings')
|
||||||
|
expect (resolveShortcutScope ('/materials/1')).toBe ('materials')
|
||||||
|
expect (isEditableEventTarget (input)).toBe (true)
|
||||||
|
expect (isEditableEventTarget (div)).toBe (true)
|
||||||
|
expect (isEditableEventTarget (document.createElement ('button'))).toBe (false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { applyClientAnimationMode,
|
||||||
|
applyThemeTokens,
|
||||||
|
buildThemeTokens,
|
||||||
|
CLIENT_SETTINGS_STORAGE_KEY,
|
||||||
|
DARK_THEME_TOKENS,
|
||||||
|
DEFAULT_POST_LIST_ORDER,
|
||||||
|
getClientListLimit,
|
||||||
|
getClientListOrder,
|
||||||
|
getClientThemeMode,
|
||||||
|
LIGHT_THEME_TOKENS,
|
||||||
|
loadClientSettings,
|
||||||
|
setClientListSettings,
|
||||||
|
setClientThemeAppearance } from '@/lib/settings'
|
||||||
|
|
||||||
|
import type { FetchPostsOrder } from '@/types'
|
||||||
|
|
||||||
|
const installMatchMedia = (matches: boolean) => {
|
||||||
|
Object.defineProperty (window, 'matchMedia', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn ().mockImplementation (() => ({
|
||||||
|
matches,
|
||||||
|
addEventListener: vi.fn (),
|
||||||
|
removeEventListener: vi.fn () })) })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe ('settings', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
localStorage.clear ()
|
||||||
|
document.documentElement.removeAttribute ('data-animation')
|
||||||
|
document.documentElement.removeAttribute ('style')
|
||||||
|
document.documentElement.className = ''
|
||||||
|
installMatchMedia (false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('stores list settings under the shared client settings key', () => {
|
||||||
|
setClientListSettings ('postSearch', {
|
||||||
|
limit: 50,
|
||||||
|
order: 'title:asc' })
|
||||||
|
|
||||||
|
expect (getClientListLimit ('postSearch')).toBe (50)
|
||||||
|
expect (getClientListOrder<FetchPostsOrder> ('postSearch')).toBe ('title:asc')
|
||||||
|
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
|
||||||
|
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'title:asc' } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('ignores invalid list limits and keeps stored order strings', () => {
|
||||||
|
localStorage.setItem (
|
||||||
|
CLIENT_SETTINGS_STORAGE_KEY,
|
||||||
|
JSON.stringify ({ lists: { postList: { limit: 30, order: DEFAULT_POST_LIST_ORDER } } }))
|
||||||
|
|
||||||
|
expect (getClientListLimit ('postList')).toBeNull ()
|
||||||
|
expect (getClientListOrder<FetchPostsOrder> ('postList')).toBe (
|
||||||
|
DEFAULT_POST_LIST_ORDER)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('stores theme mode and light/dark slot selections in client settings', () => {
|
||||||
|
setClientThemeAppearance ({
|
||||||
|
themeMode: 'dark',
|
||||||
|
activeLightThemeSlotNo: 2,
|
||||||
|
activeDarkThemeSlotNo: 3 })
|
||||||
|
|
||||||
|
expect (getClientThemeMode ()).toBe ('dark')
|
||||||
|
expect (loadClientSettings ().appearance).toMatchObject ({
|
||||||
|
themeMode: 'dark',
|
||||||
|
activeLightThemeSlotNo: 2,
|
||||||
|
activeDarkThemeSlotNo: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('applies animation mode to the document dataset', () => {
|
||||||
|
applyClientAnimationMode ('off')
|
||||||
|
expect (document.documentElement.dataset.animation).toBe ('off')
|
||||||
|
|
||||||
|
applyClientAnimationMode ('reduced')
|
||||||
|
expect (document.documentElement.dataset.animation).toBe ('reduced')
|
||||||
|
|
||||||
|
applyClientAnimationMode ('normal')
|
||||||
|
expect (document.documentElement.dataset.animation).toBeUndefined ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('normalises TopNav menu link tokens to complete CSS colours', () => {
|
||||||
|
const tokens = buildThemeTokens ('light', {
|
||||||
|
link: '221.2 83.2% 53.3%',
|
||||||
|
topNavMenuLink: '221.2 83.2% 53.3%' })
|
||||||
|
|
||||||
|
expect (tokens.link).toBe ('221.2 83.2% 53.3%')
|
||||||
|
expect (tokens.topNavMenuLink).toBe ('hsl(221.2 83.2% 53.3%)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
|
||||||
|
expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe (
|
||||||
|
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
|
||||||
|
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
|
||||||
|
DARK_THEME_TOKENS.topNavActiveBackground)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('sets shadcn tokens and TopNav tokens with their different value formats', () => {
|
||||||
|
const tokens = buildThemeTokens ('dark', { link: '213.1 93.9% 67.8%' })
|
||||||
|
|
||||||
|
applyThemeTokens (tokens)
|
||||||
|
|
||||||
|
expect (document.documentElement.style.getPropertyValue ('--theme-link')).toBe (
|
||||||
|
'213.1 93.9% 67.8%')
|
||||||
|
expect (document.documentElement.style.getPropertyValue ('--top-nav-menu-link')).toBe (
|
||||||
|
'hsl(213.1 93.9% 67.8%)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -291,10 +291,27 @@ export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue =>
|
|||||||
|
|
||||||
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
|
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
|
||||||
const { registerHandlers } = useKeyboardShortcutSettings ()
|
const { registerHandlers } = useKeyboardShortcutSettings ()
|
||||||
|
const handlersRef = useRef (handlers)
|
||||||
|
const actionIdsKey = Object.keys (handlers).sort ().join ('|')
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
handlersRef.current = handlers
|
||||||
|
}, [handlers])
|
||||||
|
|
||||||
|
const stableHandlers = useMemo<ShortcutHandlers> (() => {
|
||||||
|
return actionIdsKey.split ('|').filter (actionId => actionId !== '')
|
||||||
|
.reduce<ShortcutHandlers> ((nextHandlers, actionId) => {
|
||||||
|
const key = actionId as ShortcutActionId
|
||||||
|
nextHandlers[key] = () => {
|
||||||
|
handlersRef.current[key]?.()
|
||||||
|
}
|
||||||
|
return nextHandlers
|
||||||
|
}, { })
|
||||||
|
}, [actionIdsKey])
|
||||||
|
|
||||||
useEffect (
|
useEffect (
|
||||||
() => registerHandlers (handlers),
|
() => registerHandlers (stableHandlers),
|
||||||
[handlers, registerHandlers],
|
[registerHandlers, stableHandlers],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import PostListPage from '@/pages/posts/PostListPage'
|
import PostListPage from '@/pages/posts/PostListPage'
|
||||||
import { buildPost, buildTag, buildWikiPage } from '@/test/factories'
|
import { buildPost, buildTag, buildWikiPage } from '@/test/factories'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
|
||||||
|
|
||||||
const postsApi = vi.hoisted (() => ({
|
const postsApi = vi.hoisted (() => ({
|
||||||
fetchPosts: vi.fn (),
|
fetchPosts: vi.fn (),
|
||||||
@@ -18,6 +19,11 @@ vi.mock ('@/lib/posts', () => postsApi)
|
|||||||
vi.mock ('@/lib/wiki', () => wikiApi)
|
vi.mock ('@/lib/wiki', () => wikiApi)
|
||||||
|
|
||||||
describe ('PostListPage', () => {
|
describe ('PostListPage', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
localStorage.clear ()
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
})
|
||||||
|
|
||||||
it ('loads posts from the current query and renders the plaza tab', async () => {
|
it ('loads posts from the current query and renders the plaza tab', async () => {
|
||||||
const tag = buildTag ({ name: '虹夏' })
|
const tag = buildTag ({ name: '虹夏' })
|
||||||
postsApi.fetchPosts.mockResolvedValueOnce ({
|
postsApi.fetchPosts.mockResolvedValueOnce ({
|
||||||
@@ -36,6 +42,7 @@ describe ('PostListPage', () => {
|
|||||||
match: 'all',
|
match: 'all',
|
||||||
page: 2,
|
page: 2,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
order: 'original_created_at:desc',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -71,4 +78,36 @@ describe ('PostListPage', () => {
|
|||||||
'/wiki/%E8%99%B9%E5%A4%8F',
|
'/wiki/%E8%99%B9%E5%A4%8F',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('uses stored list limit but keeps the fixed plaza order', async () => {
|
||||||
|
localStorage.setItem (
|
||||||
|
CLIENT_SETTINGS_STORAGE_KEY,
|
||||||
|
JSON.stringify ({ lists: { postList: { limit: 50, order: 'title:asc' } } }))
|
||||||
|
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
|
||||||
|
|
||||||
|
renderWithProviders (<PostListPage/>, { route: '/posts' })
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
|
||||||
|
expect.objectContaining ({
|
||||||
|
limit: 50,
|
||||||
|
order: 'original_created_at:desc',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('updates the limit from the tab right addon and resets to page 1', async () => {
|
||||||
|
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
|
||||||
|
|
||||||
|
renderWithProviders (<PostListPage/>, { route: '/posts?page=3' })
|
||||||
|
|
||||||
|
fireEvent.change (await screen.findByLabelText ('件数'), { target: { value: '100' } })
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
|
||||||
|
expect.objectContaining ({ page: 1, limit: 100 }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,16 +30,12 @@ describe ('PostNewPage', () => {
|
|||||||
expect (screen.getByText ('403')).toBeInTheDocument ()
|
expect (screen.getByText ('403')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('submits a new post with manual title and thumbnail settings', async () => {
|
it ('submits a new post with manual title and thumbnail fetch UI', async () => {
|
||||||
api.apiPost.mockResolvedValueOnce ({})
|
api.apiPost.mockResolvedValueOnce ({})
|
||||||
api.apiGet.mockResolvedValue ([])
|
api.apiGet.mockResolvedValue ([])
|
||||||
|
|
||||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||||
|
|
||||||
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
|
|
||||||
fireEvent.click (checkboxes[0])
|
|
||||||
fireEvent.click (checkboxes[1])
|
|
||||||
|
|
||||||
const textboxes = screen.getAllByRole ('textbox')
|
const textboxes = screen.getAllByRole ('textbox')
|
||||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||||
@@ -97,10 +93,6 @@ describe ('PostNewPage', () => {
|
|||||||
|
|
||||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||||
|
|
||||||
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
|
|
||||||
fireEvent.click (checkboxes[0])
|
|
||||||
fireEvent.click (checkboxes[1])
|
|
||||||
|
|
||||||
const textboxes = screen.getAllByRole ('textbox')
|
const textboxes = screen.getAllByRole ('textbox')
|
||||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
||||||
import { buildPost, buildTag } from '@/test/factories'
|
import { buildPost, buildTag } from '@/test/factories'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
|
||||||
|
|
||||||
const postsApi = vi.hoisted (() => ({
|
const postsApi = vi.hoisted (() => ({
|
||||||
fetchPosts: vi.fn (),
|
fetchPosts: vi.fn (),
|
||||||
@@ -18,6 +19,7 @@ vi.mock ('@/lib/api', () => api)
|
|||||||
|
|
||||||
describe ('PostSearchPage', () => {
|
describe ('PostSearchPage', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
|
localStorage.clear ()
|
||||||
vi.clearAllMocks ()
|
vi.clearAllMocks ()
|
||||||
api.apiGet.mockResolvedValue ([])
|
api.apiGet.mockResolvedValue ([])
|
||||||
})
|
})
|
||||||
@@ -40,6 +42,7 @@ describe ('PostSearchPage', () => {
|
|||||||
tags: 'x',
|
tags: 'x',
|
||||||
match: 'any',
|
match: 'any',
|
||||||
page: 2,
|
page: 2,
|
||||||
|
limit: 20,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -81,4 +84,41 @@ describe ('PostSearchPage', () => {
|
|||||||
|
|
||||||
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
|
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('uses URL limit before localStorage and stores the active list settings', async () => {
|
||||||
|
localStorage.setItem (
|
||||||
|
CLIENT_SETTINGS_STORAGE_KEY,
|
||||||
|
JSON.stringify ({ lists: { postSearch: { limit: 100, order: 'title:asc' } } }))
|
||||||
|
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
|
||||||
|
|
||||||
|
renderWithProviders (
|
||||||
|
<PostSearchPage/>,
|
||||||
|
{ route: '/posts/search?limit=50&order=created_at%3Adesc' },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
|
||||||
|
expect.objectContaining ({ limit: 50, order: 'created_at:desc' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
|
||||||
|
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'created_at:desc' } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('moves the compact limit control outside the search form and resets page on change', async () => {
|
||||||
|
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
|
||||||
|
|
||||||
|
renderWithProviders (<PostSearchPage/>, { route: '/posts/search?page=4' })
|
||||||
|
|
||||||
|
const limitSelect = await screen.findByLabelText ('件数')
|
||||||
|
expect (limitSelect.closest ('form')).toBeNull ()
|
||||||
|
|
||||||
|
fireEvent.change (limitSelect, { target: { value: '100' } })
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
|
||||||
|
expect.objectContaining ({ page: 1, limit: 100 }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { dateString } from '@/lib/utils'
|
|||||||
import { buildTag, buildUser } from '@/test/factories'
|
import { buildTag, buildUser } from '@/test/factories'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import type { NicoTag } from '@/types'
|
import type { NicoTag } from '@/types'
|
||||||
|
|
||||||
const api = vi.hoisted (() => ({
|
const api = vi.hoisted (() => ({
|
||||||
@@ -27,6 +28,7 @@ const scrollIntoView = vi.fn ()
|
|||||||
vi.mock ('@/lib/api', () => api)
|
vi.mock ('@/lib/api', () => api)
|
||||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||||
|
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
useDialogue: () => dialogue,
|
useDialogue: () => dialogue,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import TagListPage from '@/pages/tags/TagListPage'
|
import TagListPage from '@/pages/tags/TagListPage'
|
||||||
import { buildTag } from '@/test/factories'
|
import { buildTag } from '@/test/factories'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
|
||||||
|
|
||||||
const tagsApi = vi.hoisted (() => ({
|
const tagsApi = vi.hoisted (() => ({
|
||||||
fetchTags: vi.fn (),
|
fetchTags: vi.fn (),
|
||||||
@@ -12,6 +13,11 @@ const tagsApi = vi.hoisted (() => ({
|
|||||||
vi.mock ('@/lib/tags', () => tagsApi)
|
vi.mock ('@/lib/tags', () => tagsApi)
|
||||||
|
|
||||||
describe ('TagListPage', () => {
|
describe ('TagListPage', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
localStorage.clear ()
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
})
|
||||||
|
|
||||||
it ('loads tags from URL filters and renders the results table', async () => {
|
it ('loads tags from URL filters and renders the results table', async () => {
|
||||||
tagsApi.fetchTags.mockResolvedValueOnce ({
|
tagsApi.fetchTags.mockResolvedValueOnce ({
|
||||||
tags: [buildTag ({
|
tags: [buildTag ({
|
||||||
@@ -38,6 +44,8 @@ describe ('TagListPage', () => {
|
|||||||
name: '虹',
|
name: '虹',
|
||||||
category: 'character',
|
category: 'character',
|
||||||
page: 3,
|
page: 3,
|
||||||
|
limit: 20,
|
||||||
|
order: 'post_count:desc',
|
||||||
postCountGTE: 5,
|
postCountGTE: 5,
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
}),
|
}),
|
||||||
@@ -48,7 +56,7 @@ describe ('TagListPage', () => {
|
|||||||
'/tags/7',
|
'/tags/7',
|
||||||
)
|
)
|
||||||
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
|
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
|
||||||
expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1')
|
expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1')
|
||||||
expect (screen.getAllByText ('廃止')).toHaveLength (2)
|
expect (screen.getAllByText ('廃止')).toHaveLength (2)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,7 +66,7 @@ describe ('TagListPage', () => {
|
|||||||
renderWithProviders (<TagListPage/>, { route: '/tags' })
|
renderWithProviders (<TagListPage/>, { route: '/tags' })
|
||||||
|
|
||||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
|
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
|
||||||
fireEvent.change (screen.getAllByRole ('combobox')[0], {
|
fireEvent.change (screen.getAllByRole ('combobox')[1], {
|
||||||
target: { value: 'character' },
|
target: { value: 'character' },
|
||||||
})
|
})
|
||||||
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
|
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
|
||||||
@@ -82,4 +90,41 @@ describe ('TagListPage', () => {
|
|||||||
|
|
||||||
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
|
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('uses URL limit before localStorage and preserves the query order', async () => {
|
||||||
|
localStorage.setItem (
|
||||||
|
CLIENT_SETTINGS_STORAGE_KEY,
|
||||||
|
JSON.stringify ({ lists: { tagList: { limit: 100, order: 'name:asc' } } }))
|
||||||
|
tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [], count: 0 })
|
||||||
|
|
||||||
|
renderWithProviders (
|
||||||
|
<TagListPage/>,
|
||||||
|
{ route: '/tags?limit=50&order=updated_at%3Adesc' },
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (tagsApi.fetchTags).toHaveBeenCalledWith (
|
||||||
|
expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
|
||||||
|
.toMatchObject ({ lists: { tagList: { limit: 50, order: 'updated_at:desc' } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps the compact limit control outside the search form and resets page on change', async () => {
|
||||||
|
tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 })
|
||||||
|
|
||||||
|
renderWithProviders (<TagListPage/>, { route: '/tags?page=4' })
|
||||||
|
|
||||||
|
const limitSelect = await screen.findByLabelText ('件数')
|
||||||
|
expect (limitSelect.closest ('form')).toBeNull ()
|
||||||
|
|
||||||
|
fireEvent.change (limitSelect, { target: { value: '100' } })
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (tagsApi.fetchTags).toHaveBeenLastCalledWith (
|
||||||
|
expect.objectContaining ({ page: 1, limit: 100 }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const postEmbed = vi.hoisted (() => ({
|
|||||||
vi.mock ('@/lib/api', () => api)
|
vi.mock ('@/lib/api', () => api)
|
||||||
vi.mock ('@/lib/posts', () => postsApi)
|
vi.mock ('@/lib/posts', () => postsApi)
|
||||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||||
|
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
useDialogue: () => dialogue,
|
useDialogue: () => dialogue,
|
||||||
}))
|
}))
|
||||||
vi.mock ('@/components/PostEmbed', () => ({
|
vi.mock ('@/components/PostEmbed', () => ({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { buildUser } from '@/test/factories'
|
|||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
const api = vi.hoisted (() => ({
|
const api = vi.hoisted (() => ({
|
||||||
|
apiGet: vi.fn (),
|
||||||
apiPut: vi.fn (),
|
apiPut: vi.fn (),
|
||||||
isApiError: vi.fn (),
|
isApiError: vi.fn (),
|
||||||
}))
|
}))
|
||||||
@@ -27,12 +28,23 @@ describe ('SettingPage', () => {
|
|||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
vi.clearAllMocks ()
|
vi.clearAllMocks ()
|
||||||
api.isApiError.mockReturnValue (false)
|
api.isApiError.mockReturnValue (false)
|
||||||
|
api.apiGet.mockImplementation ((path: string) => {
|
||||||
|
if (path === '/users/settings')
|
||||||
|
return Promise.resolve ({
|
||||||
|
theme: 'system',
|
||||||
|
auto_fetch_title: 'manual',
|
||||||
|
auto_fetch_thumbnail: 'manual',
|
||||||
|
wiki_editor_mode: 'split' })
|
||||||
|
if (path === '/users/theme_slots')
|
||||||
|
return Promise.resolve ([])
|
||||||
|
return Promise.resolve ({})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('shows loading when user is absent', () => {
|
it ('shows loading when user is absent', () => {
|
||||||
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
|
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
|
||||||
|
|
||||||
expect (screen.getByText ('Loading...')).toBeInTheDocument ()
|
expect (screen.getAllByText ('Loading...').length).toBeGreaterThan (0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('updates the current user name', async () => {
|
it ('updates the current user name', async () => {
|
||||||
@@ -42,8 +54,9 @@ describe ('SettingPage', () => {
|
|||||||
|
|
||||||
renderWithProviders (<SettingPage user={user} setUser={setUser}/>)
|
renderWithProviders (<SettingPage user={user} setUser={setUser}/>)
|
||||||
|
|
||||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'new' } })
|
const nameInput = (await screen.findAllByRole ('textbox'))[0]
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
|
fireEvent.change (nameInput, { target: { value: 'new' } })
|
||||||
|
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (api.apiPut).toHaveBeenCalledWith (
|
expect (api.apiPut).toHaveBeenCalledWith (
|
||||||
@@ -55,7 +68,7 @@ describe ('SettingPage', () => {
|
|||||||
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
|
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
|
||||||
expect (formData.get ('name')).toBe ('new')
|
expect (formData.get ('name')).toBe ('new')
|
||||||
expect (setUser).toHaveBeenCalled ()
|
expect (setUser).toHaveBeenCalled ()
|
||||||
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' })
|
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('shows validation errors returned for the name field', async () => {
|
it ('shows validation errors returned for the name field', async () => {
|
||||||
@@ -75,10 +88,11 @@ describe ('SettingPage', () => {
|
|||||||
|
|
||||||
renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>)
|
renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>)
|
||||||
|
|
||||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } })
|
const nameInput = (await screen.findAllByRole ('textbox'))[0]
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
|
fireEvent.change (nameInput, { target: { value: '' } })
|
||||||
|
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
|
||||||
|
|
||||||
expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument ()
|
expect ((await screen.findAllByText ('名前は必須です.')).length).toBeGreaterThan (0)
|
||||||
expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true')
|
expect (nameInput).toHaveAttribute ('aria-invalid', 'true')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom'
|
import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -735,6 +735,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
state: SettingsTabDirtyState,
|
state: SettingsTabDirtyState,
|
||||||
) => {
|
) => {
|
||||||
setDirtyStates (current => {
|
setDirtyStates (current => {
|
||||||
|
if (!(state.dirty) && current[tab] == null)
|
||||||
|
return current
|
||||||
|
|
||||||
const next = { ...current }
|
const next = { ...current }
|
||||||
|
|
||||||
if (!(state.dirty))
|
if (!(state.dirty))
|
||||||
@@ -827,7 +830,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
}, nextThemeSlots)
|
}, nextThemeSlots)
|
||||||
}
|
}
|
||||||
|
|
||||||
const discardAllDirtyChanges = async (): Promise<void> => {
|
const discardAllDirtyChanges = useCallback (async (): Promise<void> => {
|
||||||
const currentDirtyStates = Object.values (dirtyStates)
|
const currentDirtyStates = Object.values (dirtyStates)
|
||||||
|
|
||||||
for (const dirtyState of currentDirtyStates)
|
for (const dirtyState of currentDirtyStates)
|
||||||
@@ -837,7 +840,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDirtyStates ({ })
|
setDirtyStates ({ })
|
||||||
}
|
}, [dirtyStates])
|
||||||
|
|
||||||
const discardCurrentTabChanges = async (): Promise<void> => {
|
const discardCurrentTabChanges = async (): Promise<void> => {
|
||||||
const currentDirtyState = dirtyStates[activeTab]
|
const currentDirtyState = dirtyStates[activeTab]
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { render } from '@testing-library/react'
|
|||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||||
|
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||||
|
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
|
||||||
|
|
||||||
import type { ReactElement, ReactNode } from 'react'
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
@@ -15,21 +19,25 @@ export const renderWithProviders = (
|
|||||||
) => {
|
) => {
|
||||||
const queryClient = new QueryClient ({
|
const queryClient = new QueryClient ({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { retry: false },
|
queries: { retry: false } },
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<MemoryRouter initialEntries={[options.route ?? '/']}>
|
<MemoryRouter initialEntries={[options.route ?? '/']}>
|
||||||
{children}
|
<DialogueProvider>
|
||||||
|
<UnsavedChangesGuardProvider>
|
||||||
|
<KeyboardShortcutsProvider>
|
||||||
|
{children}
|
||||||
|
</KeyboardShortcutsProvider>
|
||||||
|
</UnsavedChangesGuardProvider>
|
||||||
|
</DialogueProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</QueryClientProvider>)
|
</QueryClientProvider>)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryClient,
|
queryClient,
|
||||||
...render (ui, { wrapper: Wrapper }),
|
...render (ui, { wrapper: Wrapper }) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,3 +20,10 @@ window.requestAnimationFrame = callback => {
|
|||||||
callback (0)
|
callback (0)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object.defineProperty (window, 'matchMedia', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn ().mockImplementation (() => ({
|
||||||
|
addEventListener: vi.fn (),
|
||||||
|
removeEventListener: vi.fn (),
|
||||||
|
matches: false })) })
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする