v0.12.28 new modal window for event edit/delete
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-backend",
|
||||
"version": "0.12.27",
|
||||
"version": "0.12.28",
|
||||
"description": "RosterChirp backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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' });
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
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 }); }
|
||||
});
|
||||
|
||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.12.27}"
|
||||
VERSION="${1:-0.12.28}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="rosterchirp"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.12.27",
|
||||
"version": "0.12.28",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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"/>
|
||||
)}
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user