v0.12.28 new modal window for event edit/delete

This commit is contained in:
2026-03-25 13:00:43 -04:00
parent 2b2e98fa48
commit ba91fce44c
8 changed files with 130 additions and 30 deletions

View File

@@ -4,7 +4,7 @@
**RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
**Current version:** 0.12.27
**Current version:** 0.12.28
---
@@ -106,7 +106,7 @@ rosterchirp/
## Version Bump — Files to Update
When bumping the version (e.g. 0.12.27 → 0.12.28), update **all three**:
When bumping the version (e.g. 0.12.28 → 0.12.29), update **all three**:
```
backend/package.json "version": "X.Y.Z"
@@ -116,7 +116,7 @@ build.sh VERSION="${1:-X.Y.Z}"
One-liner:
```bash
OLD=0.12.27; NEW=0.12.28
OLD=0.12.28; NEW=0.12.29
sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json
sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh
```

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-backend",
"version": "0.12.27",
"version": "0.12.28",
"description": "RosterChirp backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -273,7 +273,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
// Recurring future scope — update all future occurrences
// Recurring future scope — update this and all future occurrences
if (recurringScope === 'future' && event.recurrence_rule) {
const futureEvents = await query(req.schema, `
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
@@ -282,6 +282,14 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
for (const fe of futureEvents)
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
}
// Recurring all scope — update every occurrence
if (recurringScope === 'all' && event.recurrence_rule) {
const allEvents = await query(req.schema, `
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL AND title=$3
`, [req.params.id, event.created_by, event.title]);
for (const ae of allEvents)
await applyEventUpdate(req.schema, ae.id, fields, userGroupIds);
}
// Clean up availability for users removed from groups
if (Array.isArray(userGroupIds)) {
@@ -311,9 +319,23 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id])))
return res.status(404).json({ error: 'Not found' });
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
if (!event) return res.status(404).json({ error: 'Not found' });
const { recurringScope } = req.body || {};
if (recurringScope === 'future' && event.recurrence_rule) {
// Delete this event and all future occurrences with same creator/title
await exec(req.schema, `
DELETE FROM events WHERE created_by=$1 AND recurrence_rule IS NOT NULL
AND title=$2 AND start_at >= $3
`, [event.created_by, event.title, event.start_at]);
} else if (recurringScope === 'all' && event.recurrence_rule) {
// Delete every occurrence
await exec(req.schema, `
DELETE FROM events WHERE created_by=$1 AND recurrence_rule IS NOT NULL AND title=$2
`, [event.created_by, event.title]);
} else {
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
}
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.12.27}"
VERSION="${1:-0.12.28}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp"

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.12.27",
"version": "0.12.28",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { api } from '../utils/api.js';
import ColourPickerSheet from './ColourPickerSheet.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
@@ -319,6 +320,31 @@ function MobileRow({ icon, label, children, onPress, border=true }) {
);
}
// ── Recurring choice modal ────────────────────────────────────────────────────
function RecurringChoiceModal({ title, onConfirm, onCancel }) {
const [choice, setChoice] = useState('this');
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onCancel()}>
<div className="modal" style={{maxWidth:360}}>
<h3 style={{fontSize:17,fontWeight:700,margin:'0 0 20px'}}>{title}</h3>
<div style={{display:'flex',flexDirection:'column',gap:14,marginBottom:24}}>
{[['this','This event'],['future','This and following events'],['all','All events']].map(([val,label])=>(
<label key={val} style={{display:'flex',alignItems:'center',gap:10,fontSize:14,cursor:'pointer'}}>
<input type="radio" name="rec-scope" value={val} checked={choice===val} onChange={()=>setChoice(val)} style={{accentColor:'var(--primary)',width:16,height:16}}/>
{label}
</label>
))}
</div>
<div style={{display:'flex',justifyContent:'flex-end',gap:8}}>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
<button className="btn btn-primary btn-sm" onClick={()=>onConfirm(choice)}>OK</button>
</div>
</div>
</div>,
document.body
);
}
// ── Main Mobile Event Form ────────────────────────────────────────────────────
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
const toast = useToast();
@@ -353,6 +379,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
const [description, setDescription] = useState(event?.description||'');
const [recRule, setRecRule] = useState(event?.recurrence_rule||null);
const [saving, setSaving] = useState(false);
const [showScopeModal, setShowScopeModal] = useState(false);
// Overlay state
const [showStartDate, setShowStartDate] = useState(false);
@@ -410,24 +437,22 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
setEt(toTimeIn(endIso));
}, [sd, st, typeId]);
const handle = async () => {
const handle = () => {
if(!title.trim()) return toast('Title required','error');
// Validation rules
const startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime();
const endMs = new Date(buildISO(ed, allDay?'23:59':et)).getTime();
if(ed < sd) return toast('End date cannot be before start date','error');
if(!allDay && endMs <= startMs && ed === sd) return toast('End time must be after start time, or set a later end date','error');
// No past start times for new events
if(!event && !allDay && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error');
if(!event && allDay && sd < toDateIn(new Date().toISOString())) return toast('Start date cannot be in the past','error');
if(event && event.recurrence_rule?.freq) { setShowScopeModal(true); return; }
doSave('this');
};
const doSave = async (scope) => {
setShowScopeModal(false);
setSaving(true);
try {
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
let scope = 'this';
if(event && event.recurrence_rule?.freq) {
const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only');
scope = choice ? 'future' : 'this';
}
const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : await api.createEvent(body);
onSave(r.event);
} catch(e) { toast(e.message,'error'); }
@@ -558,6 +583,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
{showStartDate && <CalendarPicker value={sd} onChange={v=>{setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>}
{showEndDate && <CalendarPicker value={ed} onChange={v=>{setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>}
{showRecurrence && <RecurrenceSheet value={recRule} onChange={v=>{setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>}
{showScopeModal && <RecurringChoiceModal title="Edit recurring event" onConfirm={doSave} onCancel={()=>setShowScopeModal(false)}/>}
{showTypeColourPicker && (
<ColourPickerSheet value={newTypeColour} onChange={setNewTypeColour} onClose={()=>setShowTypeColourPicker(false)} title="Event Type Colour"/>
)}

View File

@@ -495,6 +495,48 @@ function FormRow({ label, children, required }) {
);
}
// ── Recurring choice modal ────────────────────────────────────────────────────
function RecurringChoiceModal({ title, onConfirm, onCancel }) {
const [choice, setChoice] = useState('this');
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onCancel()}>
<div className="modal" style={{maxWidth:360}}>
<h3 style={{fontSize:17,fontWeight:700,margin:'0 0 20px'}}>{title}</h3>
<div style={{display:'flex',flexDirection:'column',gap:14,marginBottom:24}}>
{[['this','This event'],['future','This and following events'],['all','All events']].map(([val,label])=>(
<label key={val} style={{display:'flex',alignItems:'center',gap:10,fontSize:14,cursor:'pointer'}}>
<input type="radio" name="rec-scope" value={val} checked={choice===val} onChange={()=>setChoice(val)} style={{accentColor:'var(--primary)',width:16,height:16}}/>
{label}
</label>
))}
</div>
<div style={{display:'flex',justifyContent:'flex-end',gap:8}}>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
<button className="btn btn-primary btn-sm" onClick={()=>onConfirm(choice)}>OK</button>
</div>
</div>
</div>,
document.body
);
}
// ── Confirm modal (non-recurring delete) ──────────────────────────────────────
function ConfirmModal({ title, message, confirmLabel='Delete', onConfirm, onCancel }) {
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onCancel()}>
<div className="modal" style={{maxWidth:360}}>
<h3 style={{fontSize:17,fontWeight:700,margin:'0 0 12px'}}>{title}</h3>
<p style={{fontSize:14,color:'var(--text-secondary)',margin:'0 0 24px'}}>{message}</p>
<div style={{display:'flex',justifyContent:'flex-end',gap:8}}>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
<button className="btn btn-sm" style={{background:'var(--error)',color:'white'}} onClick={onConfirm}>{confirmLabel}</button>
</div>
</div>
</div>,
document.body
);
}
// ── Event Form ────────────────────────────────────────────────────────────────
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
const toast=useToast();
@@ -517,6 +559,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
const [showTypeForm,setShowTypeForm]=useState(false);
const [localTypes,setLocalTypes]=useState(eventTypes);
const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
const [showScopeModal,setShowScopeModal]=useState(false);
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
// Also initialise typeId to the default event type for new events
useEffect(()=>{
@@ -587,23 +630,22 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;});
const groupsRequired=track; // when tracking, groups are required
const handle=async()=>{
const handle=()=>{
if(!title.trim()) return toast('Title required','error');
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error');
if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error');
if(ed<sd) return toast('End date cannot be before start date','error');
if(!allDay&&ed===sd&&buildISO(ed,et)<=buildISO(sd,st)) return toast('End time must be after start time, or use a later end date','error');
// No past start times for new events
if(!event && !allDay && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error');
if(!event && allDay && sd < toDateIn(new Date().toISOString())) return toast('Start date cannot be in the past','error');
if(event && event.recurrence_rule?.freq) { setShowScopeModal(true); return; }
doSave('this');
};
const doSave=async(scope)=>{
setShowScopeModal(false);
setSaving(true);
try{
const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st),endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
let scope='this';
if(event && event.recurrence_rule?.freq) {
const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only');
scope = choice ? 'future' : 'this';
}
const r=event?await api.updateEvent(event.id,{...body,recurringScope:scope}):await api.createEvent(body);
onSave(r.event);
}catch(e){toast(e.message,'error');}finally{setSaving(false);}
@@ -715,6 +757,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
</div>
</div>
</div>
{showScopeModal&&<RecurringChoiceModal title="Edit recurring event" onConfirm={doSave} onCancel={()=>setShowScopeModal(false)}/>}
);
}
@@ -1483,15 +1526,18 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
};
const handleSaved = () => { load(); setPanel('calendar'); setEditingEvent(null); };
const handleDelete = async e => {
if (!confirm(`Delete "${e.title}"?`)) return;
const [deleteTarget, setDeleteTarget] = useState(null);
const handleDelete = (e) => setDeleteTarget(e);
const doDelete = async (scope = 'this') => {
const e = deleteTarget;
setDeleteTarget(null);
try {
await api.deleteEvent(e.id);
await api.deleteEvent(e.id, scope);
toast('Deleted','success');
setPanel('calendar');
setEditingEvent(null);
setDetailEvent(null);
load(); // reload list so deleted event disappears immediately
load();
} catch(err) { toast(err.message,'error'); }
};
@@ -1679,6 +1725,12 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
)}
</div>
{/* Delete confirmation modals */}
{deleteTarget && deleteTarget.recurrence_rule?.freq
? <RecurringChoiceModal title="Delete recurring event" onConfirm={doDelete} onCancel={()=>setDeleteTarget(null)}/>
: deleteTarget && <ConfirmModal title="Delete event" message={`Delete "${deleteTarget.title}"?`} onConfirm={()=>doDelete('this')} onCancel={()=>setDeleteTarget(null)}/>
}
{/* Fixed overlays — position:fixed so they escape layout, can live anywhere in tree */}
{isMobile && mobilePanel === 'groupManager' && (
<div style={{ position:'fixed',inset:0,zIndex:50,background:'var(--background)' }}>

View File

@@ -117,7 +117,7 @@ export const api = {
getEvent: (id) => req('GET', `/schedule/${id}`),
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
deleteEvent: (id) => req('DELETE', `/schedule/${id}`),
deleteEvent: (id, scope = 'this') => req('DELETE', `/schedule/${id}`, { recurringScope: scope }),
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }),
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`),
getPendingAvailability: () => req('GET', '/schedule/me/pending'),