diff --git a/.claude/rules/scratch-gui/smalruby-markers.md b/.claude/rules/scratch-gui/smalruby-markers.md index 8dde1b5daef..f9ea1aedb3e 100644 --- a/.claude/rules/scratch-gui/smalruby-markers.md +++ b/.claude/rules/scratch-gui/smalruby-markers.md @@ -48,6 +48,8 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一 | `src/components/gui/gui.jsx` | meshV2 classroom binding | クラス状態に応じて Mesh v2 ドメインを参加コードに固定する常時マウントの binding | | `src/components/gui/gui.jsx` | welcome modal | 初回訪問者向けウェルカムモーダル HOC の import と配置、`onShowWelcomeModal` prop | | `src/containers/gui.jsx` | welcome modal | `onShowWelcomeModal` を Redux `openWelcomeModal()` にマップ | +| `src/components/gui/gui.jsx` | DNCL mode notice | DnclModeNotice コンポーネントの import・配置・`onRequestExitDnclMode` prop | +| `src/containers/gui.jsx` | DNCL mode notice | `onRequestExitDnclMode` を Redux `setDnclMode(false)` + `requestExternalExitDnclMode()` にマップ | | `src/components/gui/gui.jsx` | Redux action props prevention | Redux action props の伝播防止 | | `src/components/gui/gui.jsx` | iPad portrait narrow desktop stage size | 744〜1023px viewport で stage を small に強制 (issue #572 Phase 3-C, #599 で 768→744 拡張) | | `src/components/gui/gui.css` | iPad portrait narrow desktop layout | 744〜1023px viewport で editor-wrapper の flex-basis を緩める (issue #572 Phase 3-C, #599 で 768→744 拡張) | @@ -99,6 +101,7 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一 | `src/containers/menu.jsx` | iPad menu item click fix | メニュー項目クリック時の `setTimeout` 遅延を 0 → 100ms に拡大。iPadOS Safari は `pointerup` から `click` 発火まで ~16–32ms 程度のラグがあり、setTimeout(0) で close すると `
  • ` が click 発火前に unmount され React onClick が skip される問題への対応 | | `src/containers/blocks.jsx` | palette-toggle initial render | `componentDidMount` 末尾で `_applyPaletteVisibility` を呼び `forceUpdate()` を起動。`this.workspace` はインスタンス変数なので `inject()` 後に再レンダーが走らず、初回 `render()` で workspace=null のまま PaletteToggle がスキップされる問題への対応 (issue #695) | | `src/containers/blocks.jsx` | iOS flyout touch bleed fix | MobileGui (SP) で「ブロックを作る」タップ時に iOS の SVG タッチイベントが「変数を作る」にも伝播する問題の修正。`handlePromptStart` を 50ms 遅延して `externalProcedureDefCallback` が先に呼ばれた場合にキャンセル (issue #698) | +| `src/containers/blocks.jsx` | DNCL block filtering | `shouldComponentUpdate` に `dnclMode` を追加して日本語モード切り替え時に即時再レンダリングを保証。import・`getToolboxXML` 内フィルター・`mapStateToProps` への `dnclMode` 追加も含む | ## 関連ファイル diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md index 96c8097eee5..fbd6b9a0276 100644 --- a/.claude/rules/scratch-gui/smalruby-prettier-files.md +++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md @@ -13,6 +13,7 @@ upstream (Scratch) ファイルは対象外。 **Smalruby 固有ディレクトリ(ディレクトリ内の全ファイルが対象):** - `src/components/auto-correct-modal/` +- `src/components/dncl-mode-notice/` - `src/components/block-display-modal/` - `src/components/classroom-modal/` - `src/components/classroom-teacher-modal/` @@ -204,6 +205,8 @@ upstream (Scratch) ファイルは対象外。 - `test/integration/version-update-notification.test.js` - `test/integration/workspace-glow-regression.test.js` - `test/unit/components/action-menu.test.jsx` +- `test/unit/components/dncl-mode-notice.test.jsx` +- `test/unit/components/extension-button-dncl.test.jsx` - `test/unit/components/connected-step.test.jsx` - `test/unit/components/mobile-bottom-tabs.test.jsx` - `test/unit/components/mobile-drawer.test.jsx` @@ -284,6 +287,7 @@ upstream (Scratch) ファイルは対象外。 - `test/unit/make-toolbox-xml-hex.test.js` - `test/unit/only-blocks-initialization.test.js` - `test/unit/reducers/cards_reducer.test.js` +- `test/unit/reducers/dncl-mode-reducer.test.js` - `test/unit/reducers/classroom-reducer.test.js` - `test/unit/reducers/menus-reducer.test.js` - `test/unit/reducers/palette-visibility-reducer.test.js` diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index 82d3fa86f96..7bab2583f56 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -37,6 +37,7 @@ src/components/* !src/components/smalrubot-firmware-modal/ !src/components/blocks-screenshot-button/ !src/components/google-drive-save-dialog/ +!src/components/dncl-mode-notice/ !src/components/koshien-test-modal/ !src/components/mobile-bottom-tabs/ !src/components/mobile-drawer/ @@ -267,6 +268,8 @@ test/unit/* # Level 3: test/unit/components/* test/unit/components/* !test/unit/components/action-menu.test.jsx +!test/unit/components/dncl-mode-notice.test.jsx +!test/unit/components/extension-button-dncl.test.jsx !test/unit/components/connected-step.test.jsx !test/unit/components/mobile-bottom-tabs.test.jsx !test/unit/components/mobile-drawer.test.jsx @@ -362,6 +365,7 @@ test/unit/lib/* # Level 3: test/unit/reducers/* test/unit/reducers/* !test/unit/reducers/cards_reducer.test.js +!test/unit/reducers/dncl-mode-reducer.test.js !test/unit/reducers/classroom-reducer.test.js !test/unit/reducers/menus-reducer.test.js !test/unit/reducers/palette-visibility-reducer.test.js diff --git a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css new file mode 100644 index 00000000000..ff9af49ffff --- /dev/null +++ b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.css @@ -0,0 +1,38 @@ +.notice { + position: absolute; + top: 0; + right: 0; + z-index: 10; + display: flex; + align-items: center; + padding: 6px 12px; + background-color: rgba(255, 243, 205, 0.95); + border-bottom: 1px solid #ffc107; + border-left: 1px solid #ffc107; + font-size: 12px; + gap: 8px; + pointer-events: auto; +} + +.message { + color: #856404; + font-weight: 500; + flex: 1; +} + +.exitButton { + background: none; + border: 1px solid #856404; + border-radius: 4px; + color: #856404; + cursor: pointer; + font-size: 11px; + padding: 3px 8px; + white-space: nowrap; + flex-shrink: 0; +} + +.exitButton:hover { + background-color: #ffc107; + color: #fff; +} diff --git a/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx new file mode 100644 index 00000000000..828881dac98 --- /dev/null +++ b/packages/scratch-gui/src/components/dncl-mode-notice/dncl-mode-notice.jsx @@ -0,0 +1,55 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import intlShape from '../../lib/intlShape.js'; +import styles from './dncl-mode-notice.css'; + +const messages = defineMessages({ + message: { + id: 'gui.dnclModeNotice.message', + description: 'Notice shown in blocks tab when DNCL mode is active', + defaultMessage: 'Japanese mode: blocks are restricted', + }, + exitButton: { + id: 'gui.dnclModeNotice.exitButton', + description: 'Button label to exit DNCL mode from the blocks tab notice', + defaultMessage: 'Return to Ruby furigana mode', + }, + exitConfirm: { + id: 'gui.extensionButton.dnclExtensionConfirm', + description: 'Confirm dialog when switching out of DNCL mode', + defaultMessage: + 'Extensions are not available in Japanese mode.\nReturn to Ruby furigana mode to enable extensions.\nSwitch now?', + }, +}); + +const DnclModeNotice = ({ dnclMode, onExitDnclMode, intl }) => { + const handleExitClick = useCallback(() => { + // eslint-disable-next-line no-alert + const confirmed = window.confirm(intl.formatMessage(messages.exitConfirm)); + if (confirmed) onExitDnclMode?.(); + }, [onExitDnclMode, intl]); + + if (!dnclMode) return null; + + return ( +
    + {intl.formatMessage(messages.message)} + +
    + ); +}; + +DnclModeNotice.propTypes = { + dnclMode: PropTypes.bool, + intl: intlShape.isRequired, + onExitDnclMode: PropTypes.func, +}; + +export default injectIntl(DnclModeNotice); diff --git a/packages/scratch-gui/src/components/extension-button/extension-button.jsx b/packages/scratch-gui/src/components/extension-button/extension-button.jsx index 0293bdf73da..2897419e6c3 100644 --- a/packages/scratch-gui/src/components/extension-button/extension-button.jsx +++ b/packages/scratch-gui/src/components/extension-button/extension-button.jsx @@ -15,33 +15,38 @@ const messages = defineMessages({ description: 'Button to add an extension in the target pane', defaultMessage: 'Add Extension' }, - // === Smalruby: Start of DNCL extension alert === - dnclExtensionDisabled: { - id: 'gui.extensionButton.dnclExtensionDisabled', - description: 'Alert message when extension button is clicked in DNCL mode', - defaultMessage: 'Extensions are not available in Japanese mode.' + // === Smalruby: Start of DNCL extension confirm === + dnclExtensionConfirm: { + id: 'gui.extensionButton.dnclExtensionConfirm', + description: 'Confirm dialog when extension button is clicked in DNCL mode', + defaultMessage: 'Extensions are not available in Japanese mode.\nReturn to Ruby furigana mode to enable extensions.\nSwitch now?' } - // === Smalruby: End of DNCL extension alert === + // === Smalruby: End of DNCL extension confirm === }); const ExtensionButton = props => { const { intl, dnclMode, - onExtensionButtonClick + onExtensionButtonClick, + onRequestExitDnclMode } = props; const {captureFocus} = useContext(ModalFocusContext); const handleExtensionButtonClick = useCallback(() => { - // === Smalruby: Start of DNCL extension alert === + // === Smalruby: Start of DNCL extension confirm === if (dnclMode) { - window.alert(intl.formatMessage(messages.dnclExtensionDisabled)); // eslint-disable-line no-alert + // eslint-disable-next-line no-alert + const confirmed = window.confirm(intl.formatMessage(messages.dnclExtensionConfirm)); + if (!confirmed) return; + onRequestExitDnclMode?.(); + onExtensionButtonClick?.(); return; } - // === Smalruby: End of DNCL extension alert === + // === Smalruby: End of DNCL extension confirm === captureFocus(); onExtensionButtonClick?.(); - }, [captureFocus, onExtensionButtonClick, dnclMode, intl]); + }, [captureFocus, onExtensionButtonClick, onRequestExitDnclMode, dnclMode, intl]); return ( @@ -68,7 +73,8 @@ const ExtensionButton = props => { ExtensionButton.propTypes = { dnclMode: PropTypes.bool, intl: intlShape.isRequired, - onExtensionButtonClick: PropTypes.func + onExtensionButtonClick: PropTypes.func, + onRequestExitDnclMode: PropTypes.func }; const ExtensionButtonIntl = injectIntl(ExtensionButton); diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 9f6dc3f281e..91d2affd331 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -24,6 +24,9 @@ import Watermark from '../../containers/watermark.jsx'; import Backpack from '../../containers/backpack.jsx'; import ExtensionsButton from '../extension-button/extension-button.jsx'; +// === Smalruby: Start of DNCL mode notice === +import DnclModeNotice from '../dncl-mode-notice/dncl-mode-notice.jsx'; +// === Smalruby: End of DNCL mode notice === import WebGlModal from '../../containers/webgl-modal.jsx'; import TipsLibrary from '../../containers/tips-library.jsx'; import Cards from '../../containers/cards.jsx'; @@ -252,6 +255,7 @@ const GUIComponent = props => { onSetPlatform, onSetTheme, onOpenClassroomModal, + onRequestExitDnclMode, // === Smalruby: End of Redux action props prevention === rubyTabVisible, showComingSoon, @@ -670,12 +674,19 @@ const GUIComponent = props => { vm={vm} colorMode={colorMode} /> + {/* === Smalruby: Start of DNCL mode notice === */} + + {/* === Smalruby: End of DNCL mode notice === */} {/* === Smalruby: Start of DNCL extension button === */} {/* === Smalruby: End of DNCL extension button === */} @@ -835,6 +846,7 @@ GUIComponent.propTypes = { onClickLogo: PropTypes.func, onCloseAccountNav: PropTypes.func, onExtensionButtonClick: PropTypes.func, + onRequestExitDnclMode: PropTypes.func, // === Smalruby: DNCL mode notice === onLogOut: PropTypes.func, onNewSpriteClick: PropTypes.func, onNewLibraryCostumeClick: PropTypes.func, diff --git a/packages/scratch-gui/src/containers/blocks.jsx b/packages/scratch-gui/src/containers/blocks.jsx index 768e60f3cb1..67983df39c4 100644 --- a/packages/scratch-gui/src/containers/blocks.jsx +++ b/packages/scratch-gui/src/containers/blocks.jsx @@ -270,7 +270,8 @@ class Blocks extends React.Component { this.props.stageSize !== nextProps.stageSize || this.props.selectedBlocks !== nextProps.selectedBlocks || this.props.tutorialAllowedBlocks !== nextProps.tutorialAllowedBlocks || - this.props.paletteVisible !== nextProps.paletteVisible + this.props.paletteVisible !== nextProps.paletteVisible || + this.props.dnclMode !== nextProps.dnclMode // === Smalruby: DNCL block filtering === ); } componentDidUpdate (prevProps) { diff --git a/packages/scratch-gui/src/containers/gui.jsx b/packages/scratch-gui/src/containers/gui.jsx index cf8468a8cb5..bfbaf12ed65 100644 --- a/packages/scratch-gui/src/containers/gui.jsx +++ b/packages/scratch-gui/src/containers/gui.jsx @@ -37,6 +37,10 @@ import {setPlatform} from '../reducers/platform'; import {setTheme} from '../reducers/settings'; import {setDynamicAssets} from '../reducers/dynamic-assets'; import {showAlertWithTimeout} from '../reducers/alerts'; +import { // === Smalruby: DNCL mode notice === + setDnclMode, + requestExternalExitDnclMode, +} from '../reducers/dncl-mode'; // === Smalruby: DNCL mode notice === import {highlightTarget} from '../reducers/targets'; import { rubyCodeShape, @@ -328,6 +332,12 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = dispatch => ({ onExtensionButtonClick: () => dispatch(openExtensionLibrary()), + // === Smalruby: Start of DNCL mode notice === + onRequestExitDnclMode: () => { + dispatch(setDnclMode(false)); + dispatch(requestExternalExitDnclMode()); + }, + // === Smalruby: End of DNCL mode notice === onActivateTab: tab => dispatch(activateTab(tab)), onUpdateDynamicAssets: dynamicAssets => dispatch(setDynamicAssets(dynamicAssets)), onActivateCostumesTab: () => dispatch(activateTab(COSTUMES_TAB_INDEX)), diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 634a6c9773e..d23502c397c 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -30,7 +30,7 @@ import RubyToBlocksConverterHOC from '../lib/ruby-to-blocks-converter-hoc.jsx'; import { containsV1Code } from '../lib/ruby-to-blocks-converter/v1-detection'; import { getUrlParams } from '../lib/url-params'; import { showAlertWithTimeout, closeAlertWithId } from '../reducers/alerts'; -import { setDnclMode as setDnclModeAction } from '../reducers/dncl-mode'; +import { setDnclMode as setDnclModeAction, clearExternalExitDnclModeRequest } from '../reducers/dncl-mode'; import { BLOCKS_TAB_INDEX, RUBY_TAB_INDEX } from '../reducers/editor-tab'; import { setAiSaveStatus, clearAiSaveStatus } from '../reducers/koshien-file'; import { closeFileMenu } from '../reducers/menus.js'; @@ -123,6 +123,8 @@ const RubyTab = (props) => { v1PromptDismissed, onDismissV1Prompt, onSetDnclMode, + exitDnclModeExternallyRequested, + onClearExitDnclModeRequest, } = props; // --- State --- @@ -1161,12 +1163,17 @@ const RubyTab = (props) => { updateRubyCodeTargetState(vm.editingTarget, rubyVersion); // Schedule DNCL switch after React re-renders with the new // Ruby code and the Monaco editor value prop is committed. + // Skip if an external exit was requested (e.g. from extension button). if (wasDncl) { - requestAnimationFrame(() => { - setTimeout(() => { - handleToggleDnclMode(); - }, 0); - }); + if (exitDnclModeExternallyRequested) { + onClearExitDnclModeRequest?.(); + } else { + requestAnimationFrame(() => { + setTimeout(() => { + handleToggleDnclMode(); + }, 0); + }); + } } } } @@ -1328,6 +1335,8 @@ RubyTab.propTypes = { v1PromptDismissed: PropTypes.bool, onDismissV1Prompt: PropTypes.func, onSetDnclMode: PropTypes.func, + exitDnclModeExternallyRequested: PropTypes.bool, + onClearExitDnclModeRequest: PropTypes.func, }; const mapStateToProps = (state) => ({ @@ -1340,6 +1349,7 @@ const mapStateToProps = (state) => ({ locale: state.locales.locale, activeTabIndex: state.scratchGui.editorTab.activeTabIndex, v1PromptDismissed: state.scratchGui.settings.v1PromptDismissed, + exitDnclModeExternallyRequested: state.scratchGui.dnclMode.exitDnclModeExternallyRequested, }); const mapDispatchToProps = (dispatch) => ({ @@ -1359,6 +1369,7 @@ const mapDispatchToProps = (dispatch) => ({ onMarkRubyTabUsed: () => dispatch(markRubyTabUsed()), onDismissV1Prompt: () => dispatch(dismissV1Prompt()), onSetDnclMode: (dnclMode) => dispatch(setDnclModeAction(dnclMode)), + onClearExitDnclModeRequest: () => dispatch(clearExternalExitDnclModeRequest()), }); const ConnectedRubyTab = RubyteeModalHOC( diff --git a/packages/scratch-gui/src/locales/en.js b/packages/scratch-gui/src/locales/en.js index 11a922e7647..05017140f3f 100644 --- a/packages/scratch-gui/src/locales/en.js +++ b/packages/scratch-gui/src/locales/en.js @@ -667,7 +667,10 @@ export default { 'gui.menuBar.tutorialTooltip': 'Try Ruby!', 'gui.welcomeTooltip.label': 'Welcome to Smalruby', 'gui.aria.clearButton': 'Clear', - 'gui.extensionButton.dnclExtensionDisabled': 'Extensions are not available in Japanese mode.', + 'gui.extensionButton.dnclExtensionConfirm': + 'Extensions are not available in Japanese mode.\nReturn to Ruby furigana mode to enable extensions.\nSwitch now?', + 'gui.dnclModeNotice.message': 'Japanese mode: blocks are restricted.', + 'gui.dnclModeNotice.exitButton': 'Return to Ruby furigana mode', 'gui.rubyTab.dnclValidationError': 'This code contains constructs not supported in Japanese mode.\nPlease use only supported instructions before switching modes.', 'gui.mobile.drawer.title': 'Menu', diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 6413854e139..6a82abe5c15 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -1003,7 +1003,10 @@ export default { 'gui.menuBar.tutorialTooltip': 'ルビーをためしてみよう!', 'gui.welcomeTooltip.label': 'スモウルビーへようこそ', 'gui.aria.clearButton': 'クリア', - 'gui.extensionButton.dnclExtensionDisabled': 'にほんごモードではかくちょうきのうはつかえません。', + 'gui.extensionButton.dnclExtensionConfirm': + 'にほんごモードではかくちょうきのうはつかえません。\nRubyふりがなモードにもどすとかくちょうきのうがつかえるようになります。\nもどしますか?', + 'gui.dnclModeNotice.message': 'にほんごモード:ブロックがせいげんされています', + 'gui.dnclModeNotice.exitButton': 'Rubyふりがなモードにもどす', 'gui.rubyTab.dnclValidationError': 'にほんごモードではたいおうしていないきじゅつです。\nたいおうしているめいれいのみにしてから、モードきりかえをおこなってください。', 'gui.mobile.drawer.title': 'メニュー', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index b4f5f47dd68..fd7875979fa 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -976,7 +976,10 @@ export default { 'gui.menuBar.tutorialTooltip': 'ルビーを試してみよう!', 'gui.welcomeTooltip.label': 'スモウルビーへようこそ', 'gui.aria.clearButton': 'クリア', - 'gui.extensionButton.dnclExtensionDisabled': '日本語モードでは拡張機能は使えません。', + 'gui.extensionButton.dnclExtensionConfirm': + '日本語モードでは拡張機能は使えません。\nRubyふりがなモードに戻すと拡張機能が使えるようになります。\n戻しますか?', + 'gui.dnclModeNotice.message': '日本語モード:ブロックが制限されています', + 'gui.dnclModeNotice.exitButton': 'Rubyふりがなモードに戻す', 'gui.rubyTab.dnclValidationError': '日本語モードでは対応していない記述です。\n対応している命令のみにしてから、モード切り替えを行ってください。', 'gui.mobile.drawer.title': 'メニュー', diff --git a/packages/scratch-gui/src/reducers/dncl-mode.js b/packages/scratch-gui/src/reducers/dncl-mode.js index b61165a9774..638aea71b30 100644 --- a/packages/scratch-gui/src/reducers/dncl-mode.js +++ b/packages/scratch-gui/src/reducers/dncl-mode.js @@ -2,6 +2,8 @@ import { getUrlParams } from '../lib/url-params'; const SET_DNCL_MODE = 'scratch-gui/dncl-mode/SET_DNCL_MODE'; +const REQUEST_EXTERNAL_EXIT_DNCL_MODE = 'scratch-gui/dncl-mode/REQUEST_EXTERNAL_EXIT_DNCL_MODE'; +const CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST = 'scratch-gui/dncl-mode/CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST'; const DNCL_MODE_KEY = 'smalruby:dnclMode'; @@ -22,6 +24,7 @@ const getInitialDnclMode = () => { const initialState = { dnclMode: getInitialDnclMode(), + exitDnclModeExternallyRequested: false, }; const reducer = function (state, action) { @@ -34,6 +37,14 @@ const reducer = function (state, action) { return Object.assign({}, state, { dnclMode: action.dnclMode, }); + case REQUEST_EXTERNAL_EXIT_DNCL_MODE: + return Object.assign({}, state, { + exitDnclModeExternallyRequested: true, + }); + case CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST: + return Object.assign({}, state, { + exitDnclModeExternallyRequested: false, + }); default: return state; } @@ -46,4 +57,19 @@ const setDnclMode = function (dnclMode) { }; }; -export { reducer as default, initialState as dnclModeInitialState, setDnclMode, SET_DNCL_MODE }; +const requestExternalExitDnclMode = function () { + return { type: REQUEST_EXTERNAL_EXIT_DNCL_MODE }; +}; + +const clearExternalExitDnclModeRequest = function () { + return { type: CLEAR_EXTERNAL_EXIT_DNCL_MODE_REQUEST }; +}; + +export { + reducer as default, + initialState as dnclModeInitialState, + setDnclMode, + SET_DNCL_MODE, + requestExternalExitDnclMode, + clearExternalExitDnclModeRequest, +}; diff --git a/packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx b/packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx new file mode 100644 index 00000000000..c03cab9bf7b --- /dev/null +++ b/packages/scratch-gui/test/unit/components/dncl-mode-notice.test.jsx @@ -0,0 +1,61 @@ +/* eslint-env jest */ +import '@testing-library/jest-dom'; +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import DnclModeNotice from '../../../src/components/dncl-mode-notice/dncl-mode-notice.jsx'; + +const renderNotice = (props) => + render( + + + , + ); + +describe('DnclModeNotice', () => { + let onExitDnclMode; + + beforeEach(() => { + onExitDnclMode = jest.fn(); + jest.spyOn(window, 'confirm').mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('renders when dnclMode is true', () => { + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + expect(getByTestId('dncl-mode-notice')).toBeInTheDocument(); + }); + + test('does not render when dnclMode is false', () => { + const { queryByTestId } = renderNotice({ dnclMode: false, onExitDnclMode }); + expect(queryByTestId('dncl-mode-notice')).not.toBeInTheDocument(); + }); + + test('exit button is present', () => { + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + expect(getByTestId('dncl-mode-notice-exit-button')).toBeInTheDocument(); + }); + + test('shows confirm dialog when exit button is clicked', () => { + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + fireEvent.click(getByTestId('dncl-mode-notice-exit-button')); + expect(window.confirm).toHaveBeenCalledTimes(1); + }); + + test('does not call onExitDnclMode when confirm is cancelled', () => { + window.confirm.mockReturnValue(false); + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + fireEvent.click(getByTestId('dncl-mode-notice-exit-button')); + expect(onExitDnclMode).not.toHaveBeenCalled(); + }); + + test('calls onExitDnclMode when confirm is accepted', () => { + window.confirm.mockReturnValue(true); + const { getByTestId } = renderNotice({ dnclMode: true, onExitDnclMode }); + fireEvent.click(getByTestId('dncl-mode-notice-exit-button')); + expect(onExitDnclMode).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx b/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx new file mode 100644 index 00000000000..0b7b56424f4 --- /dev/null +++ b/packages/scratch-gui/test/unit/components/extension-button-dncl.test.jsx @@ -0,0 +1,91 @@ +/* eslint-env jest */ +import '@testing-library/jest-dom'; +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import ExtensionButton from '../../../src/components/extension-button/extension-button.jsx'; +import { ModalFocusContext } from '../../../src/contexts/modal-focus-context.jsx'; + +const mockFocusContext = { captureFocus: jest.fn(), restoreFocus: jest.fn() }; + +const renderButton = (props) => + render( + + + + + , + ); + +describe('ExtensionButton in DNCL mode', () => { + let onExtensionButtonClick; + let onRequestExitDnclMode; + + beforeEach(() => { + onExtensionButtonClick = jest.fn(); + onRequestExitDnclMode = jest.fn(); + jest.spyOn(window, 'confirm').mockReturnValue(false); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('shows confirm dialog (not alert) when clicked in DNCL mode', () => { + const { getByTestId } = renderButton({ + dnclMode: true, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(window.alert).not.toHaveBeenCalled(); + }); + + test('does not open extension library when confirm is cancelled', () => { + window.confirm.mockReturnValue(false); + const { getByTestId } = renderButton({ + dnclMode: true, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(onExtensionButtonClick).not.toHaveBeenCalled(); + expect(onRequestExitDnclMode).not.toHaveBeenCalled(); + }); + + test('dispatches exit request and opens extension library when confirm is accepted', () => { + window.confirm.mockReturnValue(true); + const { getByTestId } = renderButton({ + dnclMode: true, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(onRequestExitDnclMode).toHaveBeenCalledTimes(1); + expect(onExtensionButtonClick).toHaveBeenCalledTimes(1); + }); + + test('opens extension library directly when not in DNCL mode', () => { + const { getByTestId } = renderButton({ + dnclMode: false, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(window.confirm).not.toHaveBeenCalled(); + expect(onExtensionButtonClick).toHaveBeenCalledTimes(1); + }); + + test('does not call confirm or open library when dnclMode is undefined', () => { + const { getByTestId } = renderButton({ + dnclMode: undefined, + onExtensionButtonClick, + onRequestExitDnclMode, + }); + fireEvent.click(getByTestId('extension-button')); + expect(window.confirm).not.toHaveBeenCalled(); + expect(onExtensionButtonClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js b/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js index be25a796da1..6cbfaba52e8 100644 --- a/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js +++ b/packages/scratch-gui/test/unit/containers/ruby-tab-project-changed.test.js @@ -122,6 +122,7 @@ describe('Ruby tab projectChanged on edit', () => { alerts: { alertsList: [] }, tutorialOnboarding: { rubyTabUsed: true }, koshienFile: { aiSaveStatus: null }, + dnclMode: { dnclMode: false, exitDnclModeExternallyRequested: false }, }, locales: { locale: 'en' }, }); diff --git a/packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js b/packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js new file mode 100644 index 00000000000..25161836249 --- /dev/null +++ b/packages/scratch-gui/test/unit/reducers/dncl-mode-reducer.test.js @@ -0,0 +1,70 @@ +import reducer, { + dnclModeInitialState, + setDnclMode, + requestExternalExitDnclMode, + clearExternalExitDnclModeRequest, +} from '../../../src/reducers/dncl-mode'; + +describe('dncl-mode reducer', () => { + describe('initial state', () => { + test('should have dnclMode as false by default', () => { + expect(dnclModeInitialState.dnclMode).toBe(false); + }); + + test('should have exitDnclModeExternallyRequested as false', () => { + expect(dnclModeInitialState.exitDnclModeExternallyRequested).toBe(false); + }); + }); + + describe('setDnclMode', () => { + test('should set dnclMode to true', () => { + const state = reducer(dnclModeInitialState, setDnclMode(true)); + expect(state.dnclMode).toBe(true); + }); + + test('should set dnclMode to false', () => { + const state = reducer({ ...dnclModeInitialState, dnclMode: true }, setDnclMode(false)); + expect(state.dnclMode).toBe(false); + }); + + test('should not affect exitDnclModeExternallyRequested', () => { + const state = reducer(dnclModeInitialState, setDnclMode(true)); + expect(state.exitDnclModeExternallyRequested).toBe(false); + }); + }); + + describe('requestExternalExitDnclMode', () => { + test('should set exitDnclModeExternallyRequested to true', () => { + const state = reducer(dnclModeInitialState, requestExternalExitDnclMode()); + expect(state.exitDnclModeExternallyRequested).toBe(true); + }); + + test('should not affect dnclMode', () => { + const state = reducer({ ...dnclModeInitialState, dnclMode: true }, requestExternalExitDnclMode()); + expect(state.dnclMode).toBe(true); + }); + }); + + describe('clearExternalExitDnclModeRequest', () => { + test('should reset exitDnclModeExternallyRequested to false', () => { + const active = reducer(dnclModeInitialState, requestExternalExitDnclMode()); + expect(active.exitDnclModeExternallyRequested).toBe(true); + + const cleared = reducer(active, clearExternalExitDnclModeRequest()); + expect(cleared.exitDnclModeExternallyRequested).toBe(false); + }); + + test('should not affect dnclMode', () => { + const active = { ...dnclModeInitialState, dnclMode: true, exitDnclModeExternallyRequested: true }; + const state = reducer(active, clearExternalExitDnclModeRequest()); + expect(state.dnclMode).toBe(true); + }); + }); + + describe('unknown action', () => { + test('should return state unchanged', () => { + const state = reducer(dnclModeInitialState, { type: 'UNKNOWN' }); + expect(state).toEqual(dnclModeInitialState); + }); + }); +});