diff --git a/src/components/ag-grid/station-ag-grid.tsx b/src/components/ag-grid/station-ag-grid.tsx index 4c31dcc4c..414020085 100644 --- a/src/components/ag-grid/station-ag-grid.tsx +++ b/src/components/ag-grid/station-ag-grid.tsx @@ -13,7 +13,7 @@ import { import { RmgStyle, SidePanelMode, StationInfo } from '../../constants/constants'; import { useTranslation } from 'react-i18next'; import { HStack } from '@chakra-ui/react'; -import { setIsShareTrackEnabled, setSelectedStation, setSidePanelMode } from '../../redux/app/app-slice'; +import { setIsShareTrackEnabled, setSelectedStations, setSidePanelMode } from '../../redux/app/app-slice'; import { getRowSpanForColine } from '../../redux/param/coline-action'; import GzmtrStationCode from './gzmtr-station-code'; import { MonoColour } from '@railmapgen/rmg-palette-resources'; @@ -31,7 +31,7 @@ interface StationAgGridProps { type RowDataType = StationInfo & { id: string; rowSpan: [number, string | undefined] }; const rowSelection: RowSelectionOptions = { - mode: 'singleRow', + mode: 'multiRow', checkboxes: false, enableClickSelection: true, }; @@ -168,7 +168,7 @@ export default function StationAgGrid(props: StationAgGridProps) { if (selectedRowIds?.length) { dispatch(setSidePanelMode(SidePanelMode.STATION)); - dispatch(setSelectedStation(selectedRowIds[0])); + dispatch(setSelectedStations(selectedRowIds)); dispatch(setIsShareTrackEnabled(undefined)); } }, []); diff --git a/src/components/modal/add-station-modal.test.tsx b/src/components/modal/add-station-modal.test.tsx index 1d1370345..ba5c8154a 100644 --- a/src/components/modal/add-station-modal.test.tsx +++ b/src/components/modal/add-station-modal.test.tsx @@ -131,7 +131,7 @@ describe('AddStationModal', () => { expect(addedStations).toHaveLength(1); // open side panel - expect(mockStore.getState().app.selectedStation).toBe(addedStations[0]); + expect(mockStore.getState().app.selectedStations[0]).toBe(addedStations[0]); expect(mockStore.getState().app.sidePanelMode).toBe(SidePanelMode.STATION); }); }); diff --git a/src/components/modal/remove-confirm-modal.test.tsx b/src/components/modal/remove-confirm-modal.test.tsx index 696d5fb98..325c3f778 100644 --- a/src/components/modal/remove-confirm-modal.test.tsx +++ b/src/components/modal/remove-confirm-modal.test.tsx @@ -42,7 +42,7 @@ describe('RemoveConfirmModal', () => { const mockStore = createTestStore({ app: { ...realStore.app, - selectedStation: 'stn1', + selectedStations: ['stn1'], }, param: { ...realStore.param, @@ -99,7 +99,7 @@ describe('RemoveConfirmModal', () => { const mockStore = createTestStore({ app: { ...realStore.app, - selectedStation: 'stn2', + selectedStations: ['stn2'], }, param: { ...realStore.param, @@ -115,6 +115,6 @@ describe('RemoveConfirmModal', () => { // assertions expect(mockStore.getState().param.stn_list).not.toHaveProperty('stn2'); // removal of station expect(mockStore.getState().app.sidePanelMode).toBe(SidePanelMode.CLOSE); // close side panel - expect(mockStore.getState().app.selectedStation).toBe('linestart'); // reset station selection + expect(mockStore.getState().app.selectedStations[0]).toBe('linestart'); // reset station selection }); }); diff --git a/src/components/modal/remove-confirm-modal.tsx b/src/components/modal/remove-confirm-modal.tsx index b5c3ee209..2e40ca17d 100644 --- a/src/components/modal/remove-confirm-modal.tsx +++ b/src/components/modal/remove-confirm-modal.tsx @@ -30,7 +30,7 @@ export default function RemoveConfirmModal(props: RemoveConfirmModalProps) { const { t } = useTranslation(); const dispatch = useRootDispatch(); - const selectedStation = useRootSelector(state => state.app.selectedStation); + const selectedStation = useRootSelector(state => state.app.selectedStations[0]); const [error, setError] = useState(false); diff --git a/src/components/side-panel/batch-station-edit-panel.tsx b/src/components/side-panel/batch-station-edit-panel.tsx new file mode 100644 index 000000000..fa4b5e648 --- /dev/null +++ b/src/components/side-panel/batch-station-edit-panel.tsx @@ -0,0 +1,92 @@ +import { Box, Button, HStack, Text, VStack } from '@chakra-ui/react'; +import { RmgFields, RmgSidePanelFooter } from '@railmapgen/rmg-components'; +import { useTranslation } from 'react-i18next'; +import { Facilities, Services, TEMP } from '../../constants/constants'; +import { useRootDispatch, useRootSelector } from '../../redux'; +import { updateStationsProperty } from '../../redux/param/action'; +import { checkStationCouldBeRemoved, removeStation } from '../../redux/param/remove-station-action'; +import { setSelectedStations } from '../../redux/app/app-slice'; + +import { SidePanelMode } from '../../constants/constants'; +import { setSidePanelMode } from '../../redux/app/app-slice'; +import { useStationEditFields } from './station-side-panel/use-station-edit-fields'; + +export default function BatchStationEditPanel() { + const { t } = useTranslation(); + const dispatch = useRootDispatch(); + + const selectedStations = useRootSelector(state => state.app.selectedStations); + const { style, stn_list, loop } = useRootSelector(state => state.param); + + if (!selectedStations || selectedStations.length <= 1) return null; + + const firstStation = stn_list[selectedStations[0]]; + const commonServices = firstStation?.services || []; + const commonFacility = firstStation?.facility || ''; + const commonOneLine = firstStation?.one_line || false; + const commonIntPadding = firstStation?.int_padding; + const commonCharacterSpacing = firstStation?.character_spacing; + const commonUnderConstruction = firstStation?.underConstruction; + + const fields = useStationEditFields({ + style, + loop, + values: { + services: commonServices, + facility: commonFacility, + one_line: commonOneLine, + int_padding: commonIntPadding, + character_spacing: commonCharacterSpacing, + underConstruction: commonUnderConstruction, + }, + handlers: { + onServicesChange: (val: Services[]) => dispatch(updateStationsProperty(selectedStations, 'services', val)), + onFacilityChange: (val: string | number) => + dispatch(updateStationsProperty(selectedStations, 'facility', val as Facilities)), + onOneLineChange: (val: boolean) => dispatch(updateStationsProperty(selectedStations, 'one_line', val)), + onIntPaddingChange: (val: number) => dispatch(updateStationsProperty(selectedStations, 'int_padding', val)), + onCharacterSpacingChange: (val: number) => + dispatch(updateStationsProperty(selectedStations, 'character_spacing', val)), + onUnderConstructionChange: (val: boolean | TEMP) => + dispatch(updateStationsProperty(selectedStations, 'underConstruction', val)), + }, + }); + + const handleDelete = () => { + dispatch((dispatch, getState) => { + const currentSelected = getState().app.selectedStations; + currentSelected.forEach(id => { + if (getState().param.stn_list[id]) { + if (checkStationCouldBeRemoved(id)(dispatch, getState)) { + dispatch(removeStation(id)); + } + } + }); + dispatch(setSelectedStations([])); + dispatch(setSidePanelMode(SidePanelMode.CLOSE)); + }); + }; + + return ( + + + + {t('StationSidePanel.batch_selected', { + count: selectedStations.length, + defaultValue: `Selected ${selectedStations.length} stations`, + })} + + + + + + + + + + + ); +} + diff --git a/src/components/side-panel/side-panel.tsx b/src/components/side-panel/side-panel.tsx index 7751de676..232780e13 100644 --- a/src/components/side-panel/side-panel.tsx +++ b/src/components/side-panel/side-panel.tsx @@ -3,6 +3,7 @@ import { useRootSelector } from '../../redux'; import { closePaletteAppClip, onPaletteAppClipEmit, setSidePanelMode } from '../../redux/app/app-slice'; import { useDispatch } from 'react-redux'; import { SidePanelMode } from '../../constants/constants'; +import BatchStationEditPanel from './batch-station-edit-panel'; import StationSidePanel from './station-side-panel/station-side-panel'; import StyleSidePanel from './style-side-panel/style-side-panel'; import { RmgMultiLineString, RmgSidePanel, RmgSidePanelHeader } from '@railmapgen/rmg-components'; @@ -17,14 +18,20 @@ export default function SidePanel() { const { t } = useTranslation(); const dispatch = useDispatch(); - const { sidePanelMode, selectedStation, paletteAppClipInput } = useRootSelector(state => state.app); + const { sidePanelMode, selectedStations, paletteAppClipInput } = useRootSelector(state => state.app); + const selectedStation = selectedStations[0] || 'linestart'; const name = useRootSelector(state => state.param.stn_list[selectedStation]?.localisedName); const mode: Record = { STATION: { - header: , - body: , - footer: , + header: + selectedStations && selectedStations.length > 1 ? ( + t('StationSidePanel.batch_edit') + ) : ( + + ), + body: selectedStations && selectedStations.length > 1 ? : , + footer: selectedStations && selectedStations.length > 1 ? null : , }, STYLE: { header: t('StyleSidePanel.header'), body: }, BRANCH: { header: t('BranchSidePanel.header'), body: }, diff --git a/src/components/side-panel/station-side-panel/branch-section.tsx b/src/components/side-panel/station-side-panel/branch-section.tsx index fedeba70b..65bdd46e0 100644 --- a/src/components/side-panel/station-side-panel/branch-section.tsx +++ b/src/components/side-panel/station-side-panel/branch-section.tsx @@ -13,7 +13,7 @@ export default function BranchSection() { const { t } = useTranslation(); const dispatch = useRootDispatch(); - const selectedStation = useRootSelector(state => state.app.selectedStation); + const selectedStation = useRootSelector(state => state.app.selectedStations[0]); const stationList = useRootSelector(state => state.param.stn_list); const { parents, children, branch } = stationList[selectedStation]; diff --git a/src/components/side-panel/station-side-panel/info-section.tsx b/src/components/side-panel/station-side-panel/info-section.tsx index 45cbcbd62..c22e0b98f 100644 --- a/src/components/side-panel/station-side-panel/info-section.tsx +++ b/src/components/side-panel/station-side-panel/info-section.tsx @@ -14,7 +14,7 @@ export default function InfoSection() { const { t } = useTranslation(); const dispatch = useRootDispatch(); - const selectedStation = useRootSelector(state => state.app.selectedStation); + const selectedStation = useRootSelector(state => state.app.selectedStations[0]); console.log('InfoSection:: Rendering for', selectedStation); const style = useRootSelector(state => state.param.style); const { num, localisedName, localisedSecondaryName } = useRootSelector( diff --git a/src/components/side-panel/station-side-panel/interchange-section.test.tsx b/src/components/side-panel/station-side-panel/interchange-section.test.tsx index 408f7868c..03fd3c440 100644 --- a/src/components/side-panel/station-side-panel/interchange-section.test.tsx +++ b/src/components/side-panel/station-side-panel/interchange-section.test.tsx @@ -12,7 +12,7 @@ const testStationId = realStore.param.stn_list.linestart.children[0]; describe('InterchangeSection', () => { it('Can render InterchangeCard with headings as expected', () => { const mockStore = createTestStore({ - app: { ...realStore.app, selectedStation: testStationId }, + app: { ...realStore.app, selectedStations: [testStationId] }, param: { ...realStore.param, style: RmgStyle.GZMTR, @@ -42,7 +42,7 @@ describe('InterchangeSection', () => { it('Can handle add interchange group as expected', () => { const mockStore = createTestStore({ - app: { ...realStore.app, selectedStation: testStationId }, + app: { ...realStore.app, selectedStations: [testStationId] }, param: { ...realStore.param, style: RmgStyle.GZMTR, diff --git a/src/components/side-panel/station-side-panel/interchange-section.tsx b/src/components/side-panel/station-side-panel/interchange-section.tsx index f25dd300d..74f21f5f2 100644 --- a/src/components/side-panel/station-side-panel/interchange-section.tsx +++ b/src/components/side-panel/station-side-panel/interchange-section.tsx @@ -20,7 +20,7 @@ export default function InterchangeSection() { const { t } = useTranslation(); const dispatch = useRootDispatch(); - const selectedStation = useRootSelector(state => state.app.selectedStation); + const selectedStation = useRootSelector(state => state.app.selectedStations[0]); const { theme, style } = useRootSelector(state => state.param); const { transfer } = useRootSelector(state => state.param.stn_list[selectedStation]); diff --git a/src/components/side-panel/station-side-panel/more-section.tsx b/src/components/side-panel/station-side-panel/more-section.tsx index 3cf15d8ea..0f311989e 100644 --- a/src/components/side-panel/station-side-panel/more-section.tsx +++ b/src/components/side-panel/station-side-panel/more-section.tsx @@ -1,7 +1,7 @@ import { Box, Heading } from '@chakra-ui/react'; -import { RmgButtonGroup, RmgFields, RmgFieldsField } from '@railmapgen/rmg-components'; +import { RmgFields } from '@railmapgen/rmg-components'; import { useTranslation } from 'react-i18next'; -import { FACILITIES, Facilities, RmgStyle, Services, TEMP } from '../../../constants/constants'; +import { Facilities, Services, TEMP } from '../../../constants/constants'; import { useRootDispatch, useRootSelector } from '../../../redux'; import { updateStationCharacterSpacing, @@ -14,136 +14,43 @@ import { updateStationServices, updateStationUnderConstruction, } from '../../../redux/param/action'; +import { useStationEditFields } from './use-station-edit-fields'; export default function MoreSection() { const { t } = useTranslation(); const dispatch = useRootDispatch(); - const selectedStation = useRootSelector(state => state.app.selectedStation); + const selectedStation = useRootSelector(state => state.app.selectedStations[0]); const { style, loop } = useRootSelector(state => state.param); const { services, facility, loop_pivot, one_line, int_padding, character_spacing, underConstruction } = useRootSelector(state => state.param.stn_list[selectedStation]); - const serviceSelections = Object.values(Services).map(service => { - return { - label: t('StationSidePanel.more.' + service), - value: service, - disabled: service === Services.local && style !== RmgStyle.SHMetro, - }; - }); - - const mtrFacilityOptions = Object.fromEntries( - Object.entries(FACILITIES) - .filter(([f]) => !['railway'].includes(f)) - .map(([f, name]) => [f, t(name)]) - ); - const shmetroFacilityOptions = Object.fromEntries( - Object.entries(FACILITIES) - .filter(([f]) => !['np360'].includes(f)) - .map(([f, name]) => [f, t(name)]) - ); - - const fields: RmgFieldsField[] = [ - { - type: 'custom', - label: t('StationSidePanel.more.service'), - component: ( - dispatch(updateStationServices(selectedStation, services))} - multiSelect - /> - ), - hidden: ![RmgStyle.GZMTR, RmgStyle.SHMetro].includes(style), - }, - { - type: 'select', - label: t('StationSidePanel.more.facility'), - value: facility || '', - options: { '': t('None'), ...(style === RmgStyle.MTR ? mtrFacilityOptions : shmetroFacilityOptions) }, - onChange: value => dispatch(updateStationFacility(selectedStation, value as Facilities | '')), - hidden: ![RmgStyle.MTR, RmgStyle.SHMetro].includes(style), - }, - { - type: 'switch', - label: t('StationSidePanel.more.pivot'), - isChecked: loop_pivot, - onChange: checked => dispatch(updateStationLoopPivot(selectedStation, checked)), - hidden: ![RmgStyle.GZMTR, RmgStyle.SHMetro].includes(style) || !loop, - minW: 'full', - oneLine: true, + const fields = useStationEditFields({ + style, + loop, + values: { + services, + facility: facility || '', + loop_pivot, + one_line, + int_padding, + character_spacing, + underConstruction, }, - { - type: 'switch', - label: t('StationSidePanel.more.oneLine'), - isChecked: one_line, - onChange: checked => dispatch(updateStationOneLine(selectedStation, checked)), - hidden: ![RmgStyle.SHMetro].includes(style), - minW: 'full', - oneLine: true, + handlers: { + onServicesChange: (val: Services[]) => dispatch(updateStationServices(selectedStation, val)), + onFacilityChange: (val: string | number) => + dispatch(updateStationFacility(selectedStation, val as Facilities | '')), + onLoopPivotChange: (val: boolean) => dispatch(updateStationLoopPivot(selectedStation, val)), + onOneLineChange: (val: boolean) => dispatch(updateStationOneLine(selectedStation, val)), + onIntPaddingChange: (val: number) => dispatch(updateStationIntPadding(selectedStation, val)), + onCharacterSpacingChange: (val: number) => dispatch(updateStationCharacterSpacing(selectedStation, val)), + onUnderConstructionChange: (val: boolean | TEMP) => + dispatch(updateStationUnderConstruction(selectedStation, val)), + onApplyIntPaddingToAll: () => dispatch(updateStationIntPaddingToAll(selectedStation)), + onApplyCharacterSpacingToAll: () => dispatch(updateStationCharacterSpacingToAll(selectedStation)), }, - { - type: 'input', - label: t('StationSidePanel.more.intPadding'), - value: int_padding.toString(), - validator: val => Number.isInteger(val), - onChange: val => dispatch(updateStationIntPadding(selectedStation, Number(val))), - hidden: ![RmgStyle.SHMetro].includes(style), - }, - { - type: 'custom', - label: t('StationSidePanel.more.intPaddingApplyGlobal'), - component: ( - dispatch(updateStationIntPaddingToAll(selectedStation))} - /> - ), - oneLine: true, - hidden: ![RmgStyle.SHMetro].includes(style), - }, - { - type: 'input', - label: t('StationSidePanel.more.characterSpacing'), - value: character_spacing.toString(), - validator: val => Number.isInteger(val), - onChange: val => dispatch(updateStationCharacterSpacing(selectedStation, Number(val))), - hidden: ![RmgStyle.SHSuburbanRailway].includes(style), - }, - { - type: 'custom', - label: t('StationSidePanel.more.intPaddingApplyGlobal'), - component: ( - dispatch(updateStationCharacterSpacingToAll(selectedStation))} - /> - ), - oneLine: true, - hidden: ![RmgStyle.SHSuburbanRailway].includes(style), - }, - { - type: 'custom', - label: t('Under construction'), - component: ( - dispatch(updateStationUnderConstruction(selectedStation, uc))} - /> - ), - hidden: ![RmgStyle.GZMTR].includes(style), - }, - ]; + }); return ( @@ -155,3 +62,4 @@ export default function MoreSection() { ); } + diff --git a/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx b/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx index f1dc9cfe1..c5c07f542 100644 --- a/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx +++ b/src/components/side-panel/station-side-panel/station-side-panel-footer.tsx @@ -11,7 +11,7 @@ export default function StationSidePanelFooter() { const { t } = useTranslation(); const dispatch = useRootDispatch(); - const { selectedStation } = useRootSelector(state => state.app); + const selectedStation = useRootSelector(state => state.app.selectedStations[0]); const { loop, style } = useRootSelector(state => state.param); const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false); diff --git a/src/components/side-panel/station-side-panel/use-station-edit-fields.tsx b/src/components/side-panel/station-side-panel/use-station-edit-fields.tsx new file mode 100644 index 000000000..33fb97041 --- /dev/null +++ b/src/components/side-panel/station-side-panel/use-station-edit-fields.tsx @@ -0,0 +1,185 @@ +import { useTranslation } from 'react-i18next'; +import { FACILITIES, RmgStyle, Services, TEMP } from '../../../constants/constants'; +import { RmgButtonGroup, RmgFieldsField } from '@railmapgen/rmg-components'; + +interface UseStationEditFieldsProps { + style: RmgStyle; + values: { + services: Services[]; + facility: string; + loop_pivot?: boolean; + one_line: boolean; + int_padding?: number; + character_spacing?: number; + underConstruction?: boolean | TEMP; + }; + handlers: { + onServicesChange: (val: Services[]) => void; + onFacilityChange: (val: string | number) => void; + onLoopPivotChange?: (val: boolean) => void; + onOneLineChange: (val: boolean) => void; + onIntPaddingChange: (val: number) => void; + onCharacterSpacingChange: (val: number) => void; + onUnderConstructionChange: (val: boolean | TEMP) => void; + // Optional global apply handlers (only for single station mode) + onApplyIntPaddingToAll?: () => void; + onApplyCharacterSpacingToAll?: () => void; + }; + loop?: boolean; +} + +// Constants for configuration +const SERVICE_ALLOW_CONFIG: Record = { + [RmgStyle.GZMTR]: [Services.express, Services.direct], // Only express and direct services allowed for Guangzhou Metro + default: Object.values(Services), // All services allowed by default (e.g. for SHMetro) +}; + +const FACILITY_ALLOW_CONFIG: Record = { + [RmgStyle.MTR]: ['airport', 'hsr', 'disney', 'np360'], + [RmgStyle.SHMetro]: ['airport', 'hsr', 'railway', 'disney'], + default: Object.keys(FACILITIES), +}; + +export const useStationEditFields = (props: UseStationEditFieldsProps): RmgFieldsField[] => { + const { t } = useTranslation(); + const { style, values, handlers, loop } = props; + + // Filter available train services based on current style + const allowedServices = SERVICE_ALLOW_CONFIG[style] || SERVICE_ALLOW_CONFIG.default; + const serviceSelections = Object.values(Services).map(service => ({ + label: t('StationSidePanel.more.' + service), + value: service, + disabled: !allowedServices.includes(service), + })); + + // Filter available facilities (like toilets, elevators) based on current style + const allowedFacilities = FACILITY_ALLOW_CONFIG[style] || FACILITY_ALLOW_CONFIG.default; + const facilityOptions = Object.fromEntries([ + ['', t('None')], + ...Object.entries(FACILITIES) + .filter(([f]) => allowedFacilities.includes(f)) + .map(([f, name]) => [f, t(name)]), + ]); + + const fields: RmgFieldsField[] = [ + { + type: 'custom', + label: t('StationSidePanel.more.service'), + component: ( + + ), + hidden: ![RmgStyle.GZMTR, RmgStyle.SHMetro].includes(style), + }, + { + type: 'select', + label: t('StationSidePanel.more.facility'), + value: values.facility, + options: facilityOptions, + onChange: handlers.onFacilityChange, + hidden: ![RmgStyle.MTR, RmgStyle.SHMetro].includes(style), + }, + // Loop Pivot (Specific to loop lines) + ...(handlers.onLoopPivotChange + ? ([ + { + type: 'switch', + label: t('StationSidePanel.more.pivot'), + isChecked: values.loop_pivot ?? false, + onChange: handlers.onLoopPivotChange, + hidden: ![RmgStyle.GZMTR, RmgStyle.SHMetro].includes(style) || !loop, + oneLine: true, + minW: 'full', + }, + ] as RmgFieldsField[]) + : []), + { + type: 'switch', + label: t('StationSidePanel.more.oneLine'), + isChecked: values.one_line, + onChange: handlers.onOneLineChange, + hidden: ![RmgStyle.SHMetro].includes(style), + oneLine: true, + minW: 'full', + }, + { + type: 'input', + label: t('StationSidePanel.more.intPadding'), + value: values.int_padding?.toString() || '', + validator: val => !isNaN(Number(val)), + onChange: val => handlers.onIntPaddingChange(Number(val)), + hidden: ![RmgStyle.SHMetro].includes(style), + debouncedDelay: 300, + }, + // Global apply for int_padding (Single station only) + // When the handler is provided, it will be appended as an additional field + ...(handlers.onApplyIntPaddingToAll + ? ([ + { + type: 'custom', + label: t('StationSidePanel.more.intPaddingApplyGlobal'), + component: ( + + ), + oneLine: true, + hidden: ![RmgStyle.SHMetro].includes(style), + }, + ] as RmgFieldsField[]) + : []), + { + type: 'input', + label: t('StationSidePanel.more.characterSpacing'), + value: values.character_spacing?.toString() || '', + validator: val => !isNaN(Number(val)), + onChange: val => handlers.onCharacterSpacingChange(Number(val)), + hidden: ![RmgStyle.SHSuburbanRailway].includes(style), + debouncedDelay: 300, + }, + // Global apply for character_spacing (Single station only) + ...(handlers.onApplyCharacterSpacingToAll + ? ([ + { + type: 'custom', + label: t('StationSidePanel.more.intPaddingApplyGlobal'), + component: ( + + ), + oneLine: true, + hidden: ![RmgStyle.SHSuburbanRailway].includes(style), + }, + ] as RmgFieldsField[]) + : []), + { + type: 'custom', + label: t('Under construction'), + component: ( + + ), + hidden: ![RmgStyle.GZMTR].includes(style), + }, + ]; + + return fields; +}; diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index 9a71177a7..67ec27d47 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -136,6 +136,8 @@ }, "StationSidePanel": { + "batch_edit": "Batch Edit", + "batch_selected": "Selected {{count}} stations", "info": { "title": "Station info", "num": "Station code", diff --git a/src/i18n/translations/ja.json b/src/i18n/translations/ja.json index 663e1c9bc..92f8d3b3b 100644 --- a/src/i18n/translations/ja.json +++ b/src/i18n/translations/ja.json @@ -134,6 +134,8 @@ }, "StationSidePanel": { + "batch_edit": "一括編集", + "batch_selected": "{{count}} 駅を選択中", "info": { "title": "駅情報", "num": "駅番号", diff --git a/src/i18n/translations/ko.json b/src/i18n/translations/ko.json index 6c9ec4086..2721ac046 100644 --- a/src/i18n/translations/ko.json +++ b/src/i18n/translations/ko.json @@ -133,6 +133,8 @@ }, "StationSidePanel": { + "batch_edit": "일괄 편집", + "batch_selected": "{{count}}개 역 선택됨", "info": { "title": "역 정보", "num": "역 번호", diff --git a/src/i18n/translations/zh-Hans.json b/src/i18n/translations/zh-Hans.json index 2941d351c..91ad70cd3 100644 --- a/src/i18n/translations/zh-Hans.json +++ b/src/i18n/translations/zh-Hans.json @@ -134,6 +134,8 @@ }, "StationSidePanel": { + "batch_edit": "批量编辑", + "batch_selected": "已选中 {{count}} 个车站", "info": { "title": "车站资讯", "num": "车站编号", diff --git a/src/i18n/translations/zh-Hant.json b/src/i18n/translations/zh-Hant.json index 45d68f690..dd1826617 100644 --- a/src/i18n/translations/zh-Hant.json +++ b/src/i18n/translations/zh-Hant.json @@ -127,6 +127,8 @@ } }, "StationSidePanel": { + "batch_edit": "批次編輯", + "batch_selected": "已選取 {{count}} 個車站", "info": { "title": "車站資訊", "num": "車站編碼", diff --git a/src/redux/app/app-slice.ts b/src/redux/app/app-slice.ts index fddd0bbf0..6b45e1dd7 100644 --- a/src/redux/app/app-slice.ts +++ b/src/redux/app/app-slice.ts @@ -13,7 +13,8 @@ interface AppState { canvasScale: number; canvasToShow: CanvasType[]; sidePanelMode: SidePanelMode; - selectedStation: string; + // selectedStation: string; // Removed as redundant + selectedStations: string[]; selectedColine?: number; selectedBranch: number; isShareTrackEnabled?: string[]; // for main line only, store the selections @@ -29,7 +30,8 @@ const initialState: AppState = { canvasScale: 1, canvasToShow: Object.values(CanvasType), sidePanelMode: SidePanelMode.CLOSE, - selectedStation: 'linestart', + // selectedStation: 'linestart', + selectedStations: ['linestart'], selectedColine: undefined, selectedBranch: 0, isShareTrackEnabled: undefined, @@ -65,7 +67,8 @@ const appSlice = createSlice({ }, setSelectedStation: (state, action: PayloadAction) => { - state.selectedStation = action.payload; + // state.selectedStation = action.payload; + state.selectedStations = [action.payload]; }, setSelectedColine: (state, action: PayloadAction) => { @@ -122,6 +125,10 @@ const appSlice = createSlice({ state.paletteAppClipOutput = action.payload; state.paletteAppClipInput = undefined; }, + + setSelectedStations: (state, action: PayloadAction) => { + state.selectedStations = action.payload; + }, }, }); @@ -143,6 +150,7 @@ export const { openPaletteAppClip, closePaletteAppClip, onPaletteAppClipEmit, + setSelectedStations, } = appSlice.actions; const appReducer = appSlice.reducer; diff --git a/src/redux/param/action.ts b/src/redux/param/action.ts index 7c7804246..40354a8d9 100644 --- a/src/redux/param/action.ts +++ b/src/redux/param/action.ts @@ -630,3 +630,23 @@ export const autoNumbering = (branchIndex: number, from: number, maxLength = 2, } }; }; + +export const updateStationsProperty = ( + stationIds: string[], + key: K, + value: StationInfo[K] +) => { + return (dispatch: RootDispatch, getState: () => RootState) => { + const { stn_list } = getState().param; + const nextStationList = { ...stn_list }; + + const uniqueIds = Array.from(new Set(stationIds)); + uniqueIds.forEach(id => { + if (nextStationList[id]) { + nextStationList[id] = { ...nextStationList[id], [key]: value }; + } + }); + + dispatch(setStationsBulk(nextStationList)); + }; +};