From 206020c1117d14f27b561e2ab9dde14253d782f6 Mon Sep 17 00:00:00 2001 From: Forfold Date: Tue, 22 Jul 2025 11:13:57 -0700 Subject: [PATCH 01/20] add gql query for associated users on a schedule --- graphql2/graphqlapp/schedule.go | 13 +++++++++++ schedule/store.go | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/graphql2/graphqlapp/schedule.go b/graphql2/graphqlapp/schedule.go index 189a157b37..c235602ef7 100644 --- a/graphql2/graphqlapp/schedule.go +++ b/graphql2/graphqlapp/schedule.go @@ -19,6 +19,7 @@ import ( "github.com/target/goalert/schedule" "github.com/target/goalert/schedule/rule" "github.com/target/goalert/search" + "github.com/target/goalert/user" "github.com/target/goalert/util" "github.com/target/goalert/validation" "github.com/target/goalert/validation/validate" @@ -172,6 +173,18 @@ func (s *Schedule) Targets(ctx context.Context, raw *schedule.Schedule) ([]graph return result, nil } +func (s *Schedule) AssociatedUsers(ctx context.Context, raw *schedule.Schedule) ([]user.User, error) { + userIDs, err := s.ScheduleStore.FindAssociatedUserIDs(ctx, raw.ID) + if err != nil { + return nil, err + } + users, err := s.UserStore.FindMany(ctx, userIDs) + if err != nil { + return nil, err + } + return users, nil +} + func (s *Schedule) AssignedTo(ctx context.Context, raw *schedule.Schedule) ([]assignment.RawTarget, error) { pols, err := s.PolicyStore.FindAllPoliciesBySchedule(ctx, raw.ID) if err != nil { diff --git a/schedule/store.go b/schedule/store.go index 861da7977d..ab4db6b33f 100644 --- a/schedule/store.go +++ b/schedule/store.go @@ -32,6 +32,8 @@ type Store struct { findMany *sql.Stmt + findAssociatedUserIDs *sql.Stmt + usr *user.Store } @@ -78,6 +80,13 @@ func NewStore(ctx context.Context, db *sql.DB, usr *user.Store) (*Store, error) `), delete: p.P(`DELETE FROM schedules WHERE id = any($1)`), + + findAssociatedUserIDs: p.P(` + SELECT DISTINCT COALESCE(s.tgt_user_id, r.user_id) + FROM schedule_rules s + LEFT JOIN rotation_participants r ON r.rotation_id = s.tgt_rotation_id + WHERE s.schedule_id = $1 + `), }, p.Err } @@ -321,3 +330,34 @@ func (store *Store) DeleteManyTx(ctx context.Context, tx *sql.Tx, ids []string) _, err = s.ExecContext(ctx, sqlutil.UUIDArray(ids)) return err } + +func (store *Store) FindAssociatedUserIDs(ctx context.Context, id string) ([]string, error) { + err := validate.UUID("ScheduleID", id) + if err != nil { + return nil, err + } + err = permission.LimitCheckAny(ctx, permission.All) + if err != nil { + return nil, err + } + + rows, err := store.findAssociatedUserIDs.QueryContext(ctx, id) + if err != nil { + return nil, err + } + defer rows.Close() + + var userIDs []string + for rows.Next() { + var userID string + if err := rows.Scan(&userID); err != nil { + return nil, err + } + userIDs = append(userIDs, userID) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return userIDs, nil +} From 2a1cce65a0e4866a06bb88b82e59a1e198807a6d Mon Sep 17 00:00:00 2001 From: Forfold Date: Tue, 22 Jul 2025 12:23:37 -0700 Subject: [PATCH 02/20] add generated files for associatedUsers --- graphql2/generated.go | 129 ++++++++++++++++++++++++++++++++++++++++ graphql2/schema.graphql | 2 + web/src/schema.d.ts | 1 + 3 files changed, 132 insertions(+) diff --git a/graphql2/generated.go b/graphql2/generated.go index dc8b923dc6..ab075dc877 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -683,6 +683,7 @@ type ComplexityRoot struct { Schedule struct { AssignedTo func(childComplexity int) int + AssociatedUsers func(childComplexity int) int Description func(childComplexity int) int ID func(childComplexity int) int IsFavorite func(childComplexity int) int @@ -1088,6 +1089,7 @@ type ScheduleResolver interface { TimeZone(ctx context.Context, obj *schedule.Schedule) (string, error) AssignedTo(ctx context.Context, obj *schedule.Schedule) ([]assignment.RawTarget, error) Shifts(ctx context.Context, obj *schedule.Schedule, start time.Time, end time.Time, userIDs []string) ([]oncall.Shift, error) + AssociatedUsers(ctx context.Context, obj *schedule.Schedule) ([]user.User, error) Targets(ctx context.Context, obj *schedule.Schedule) ([]ScheduleTarget, error) Target(ctx context.Context, obj *schedule.Schedule, input assignment.RawTarget) (*ScheduleTarget, error) IsFavorite(ctx context.Context, obj *schedule.Schedule) (bool, error) @@ -4378,6 +4380,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Schedule.AssignedTo(childComplexity), true + case "Schedule.associatedUsers": + if e.complexity.Schedule.AssociatedUsers == nil { + break + } + + return e.complexity.Schedule.AssociatedUsers(childComplexity), true + case "Schedule.description": if e.complexity.Schedule.Description == nil { break @@ -20667,6 +20676,8 @@ func (ec *executionContext) fieldContext_Mutation_createSchedule(ctx context.Con return ec.fieldContext_Schedule_assignedTo(ctx, field) case "shifts": return ec.fieldContext_Schedule_shifts(ctx, field) + case "associatedUsers": + return ec.fieldContext_Schedule_associatedUsers(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -24760,6 +24771,8 @@ func (ec *executionContext) fieldContext_Query_schedule(ctx context.Context, fie return ec.fieldContext_Schedule_assignedTo(ctx, field) case "shifts": return ec.fieldContext_Schedule_shifts(ctx, field) + case "associatedUsers": + return ec.fieldContext_Schedule_associatedUsers(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -28812,6 +28825,80 @@ func (ec *executionContext) fieldContext_Schedule_shifts(ctx context.Context, fi return fc, nil } +func (ec *executionContext) _Schedule_associatedUsers(ctx context.Context, field graphql.CollectedField, obj *schedule.Schedule) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Schedule_associatedUsers(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Schedule().AssociatedUsers(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]user.User) + fc.Result = res + return ec.marshalNUser2ᚕgithubᚗcomᚋtargetᚋgoalertᚋuserᚐUserᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Schedule_associatedUsers(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Schedule", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_User_id(ctx, field) + case "role": + return ec.fieldContext_User_role(ctx, field) + case "name": + return ec.fieldContext_User_name(ctx, field) + case "email": + return ec.fieldContext_User_email(ctx, field) + case "contactMethods": + return ec.fieldContext_User_contactMethods(ctx, field) + case "notificationRules": + return ec.fieldContext_User_notificationRules(ctx, field) + case "calendarSubscriptions": + return ec.fieldContext_User_calendarSubscriptions(ctx, field) + case "statusUpdateContactMethodID": + return ec.fieldContext_User_statusUpdateContactMethodID(ctx, field) + case "authSubjects": + return ec.fieldContext_User_authSubjects(ctx, field) + case "sessions": + return ec.fieldContext_User_sessions(ctx, field) + case "onCallSteps": + return ec.fieldContext_User_onCallSteps(ctx, field) + case "onCallOverview": + return ec.fieldContext_User_onCallOverview(ctx, field) + case "isFavorite": + return ec.fieldContext_User_isFavorite(ctx, field) + case "assignedSchedules": + return ec.fieldContext_User_assignedSchedules(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type User", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Schedule_targets(ctx context.Context, field graphql.CollectedField, obj *schedule.Schedule) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Schedule_targets(ctx, field) if err != nil { @@ -29127,6 +29214,8 @@ func (ec *executionContext) fieldContext_ScheduleConnection_nodes(_ context.Cont return ec.fieldContext_Schedule_assignedTo(ctx, field) case "shifts": return ec.fieldContext_Schedule_shifts(ctx, field) + case "associatedUsers": + return ec.fieldContext_Schedule_associatedUsers(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -32688,6 +32777,8 @@ func (ec *executionContext) fieldContext_User_assignedSchedules(_ context.Contex return ec.fieldContext_Schedule_assignedTo(ctx, field) case "shifts": return ec.fieldContext_Schedule_shifts(ctx, field) + case "associatedUsers": + return ec.fieldContext_Schedule_associatedUsers(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -32973,6 +33064,8 @@ func (ec *executionContext) fieldContext_UserCalendarSubscription_schedule(_ con return ec.fieldContext_Schedule_assignedTo(ctx, field) case "shifts": return ec.fieldContext_Schedule_shifts(ctx, field) + case "associatedUsers": + return ec.fieldContext_Schedule_associatedUsers(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -47519,6 +47612,42 @@ func (ec *executionContext) _Schedule(ctx context.Context, sel ast.SelectionSet, continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "associatedUsers": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Schedule_associatedUsers(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "targets": field := field diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index 159570e989..571532976f 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -692,6 +692,8 @@ type Schedule { userIDs: [ID!] ): [OnCallShift!]! + associatedUsers: [User!]! + targets: [ScheduleTarget!]! target(input: TargetInput!): ScheduleTarget isFavorite: Boolean! diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index fdd8c8afcb..809ba283fd 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -1004,6 +1004,7 @@ export interface SWOStatus { export interface Schedule { assignedTo: Target[] + associatedUsers: User[] description: string id: string isFavorite: boolean From f5bed925756268f89287b82cf8267efb73a6a593 Mon Sep 17 00:00:00 2001 From: Forfold Date: Tue, 22 Jul 2025 12:24:52 -0700 Subject: [PATCH 03/20] move form of temp sched to own file --- .../schedules/temp-sched/TempSchedDialog.tsx | 189 +++--------------- .../schedules/temp-sched/TempSchedForm.tsx | 179 +++++++++++++++++ .../app/schedules/temp-sched/sharedUtils.tsx | 5 +- 3 files changed, 206 insertions(+), 167 deletions(-) create mode 100644 web/src/app/schedules/temp-sched/TempSchedForm.tsx diff --git a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx index 97f190e258..96ec05d332 100644 --- a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx @@ -1,7 +1,6 @@ -import React, { useState, useRef, Suspense } from 'react' +import React, { useState, useRef, Suspense, useMemo } from 'react' import { useMutation, gql } from 'urql' import Checkbox from '@mui/material/Checkbox' -import DialogContentText from '@mui/material/DialogContentText' import FormControlLabel from '@mui/material/FormControlLabel' import FormHelperText from '@mui/material/FormHelperText' import Grid from '@mui/material/Grid' @@ -14,23 +13,15 @@ import _ from 'lodash' import { DateTime, Duration, Interval } from 'luxon' import { fieldErrors, nonFieldErrors } from '../../util/errutil' import FormDialog from '../../dialogs/FormDialog' -import { - contentText, - inferDuration, - Shift, - TempSchedValue, -} from './sharedUtils' -import { FormContainer, FormField } from '../../forms' -import TempSchedAddNewShift from './TempSchedAddNewShift' -import { isISOAfter, parseInterval } from '../../util/shifts' +import { inferDuration, Shift, TempSchedValue } from './sharedUtils' +import { FormContainer } from '../../forms' +import { parseInterval } from '../../util/shifts' import { useScheduleTZ } from '../useScheduleTZ' import TempSchedShiftsList from './TempSchedShiftsList' -import { ISODateTimePicker } from '../../util/ISOPickers' import { getCoverageGapItems } from './shiftsListUtil' -import { fmtLocal } from '../../util/timeFormat' import { ensureInterval } from '../timeUtil' import TempSchedConfirmation from './TempSchedConfirmation' -import { TextField, MenuItem, Divider } from '@mui/material' +import TempSchedForm from './TempSchedForm' const mutation = gql` mutation ($input: SetTemporaryScheduleInput!) { @@ -43,10 +34,6 @@ function shiftEquals(a: Shift, b: Shift): boolean { } const useStyles = makeStyles((theme: Theme) => ({ - contentText, - avatar: { - backgroundColor: theme.palette.primary.main, - }, formContainer: { height: '100%', }, @@ -63,13 +50,6 @@ const useStyles = makeStyles((theme: Theme) => ({ }, overflow: 'hidden', }, - sticky: { - position: 'sticky', - top: 0, - }, - tzNote: { - fontStyle: 'italic', - }, })) type TempScheduleDialogProps = { @@ -90,7 +70,7 @@ const clampForward = (nowISO: string, iso: string): string => { return iso } -interface DurationValues { +export interface DurationValues { dur: number ivl: string } @@ -102,10 +82,12 @@ export default function TempSchedDialog({ edit = false, }: TempScheduleDialogProps): JSX.Element { const classes = useStyles() - const { q, zone, isLocalZone } = useScheduleTZ(scheduleID) - const [now] = useState(DateTime.utc().startOf('minute').toISO()) + const { q, zone } = useScheduleTZ(scheduleID) + const now = useMemo(() => DateTime.utc().startOf('minute').toISO(), []) const [showForm, setShowForm] = useState(false) + const [{ fetching, error }, commit] = useMutation(mutation) + let defaultShiftDur = {} as DurationValues const getDurValues = (dur: Duration): DurationValues => { @@ -126,7 +108,7 @@ export default function TempSchedDialog({ const [durValues, setDurValues] = useState(defaultShiftDur) - const [value, setValue] = useState({ + const [value, setValue] = useState({ start: clampForward(now, _value.start), end: _value.end, clearStart: edit ? _value.start : null, @@ -157,15 +139,6 @@ export default function TempSchedDialog({ const [submitAttempt, setSubmitAttempt] = useState(false) // helps with error messaging on step 1 const [submitSuccess, setSubmitSuccess] = useState(false) - const [{ fetching, error }, commit] = useMutation(mutation) - - function validate(): Error | null { - if (isISOAfter(value.start, value.end)) { - return new Error('Start date/time cannot be after end date/time.') - } - return null - } - const hasInvalidShift = (() => { if (q.loading) return false const schedInterval = parseInterval(value, zone) @@ -287,7 +260,7 @@ export default function TempSchedDialog({ return ( {/* left pane */} - - - - The schedule will be exactly as configured here for the - entire duration (ignoring all assignments and overrides). - - - - - - Times shown in schedule timezone ({zone}) - - - - - validate()} - timeZone={zone} - disabled={q.loading} - hint={isLocalZone ? '' : fmtLocal(value.start)} - /> - - - validate()} - timeZone={zone} - disabled={q.loading} - hint={isLocalZone ? '' : fmtLocal(value.end)} - /> - - - { - setDurValues({ ...durValues, ...newValue }) - setValue({ - ...value, - shiftDur: Duration.fromObject({ - [newValue.ivl]: newValue.dur, - }), - }) - }} - > - - validate()} - disabled={q.loading} - /> - - - validate()} - disabled={q.loading} - > - Hour - Day - Week - - - - - - - - - - - setValue({ ...value, shifts }) - } - scheduleID={scheduleID} - showForm={showForm} - setShowForm={setShowForm} - shift={shift} - setShift={setShift} - /> - - + {/* right pane */} ({ + contentText, + sticky: { + position: 'sticky', + top: 0, + }, + tzNote: { + fontStyle: 'italic', + }, +})) + +interface TempSchedFormProps { + scheduleID: string + duration: DurationValues + setDuration: React.Dispatch> + value: TempSchedValue + setValue: React.Dispatch> + showForm: boolean + setShowForm: React.Dispatch> + shift: Shift + setShift: React.Dispatch> +} + +export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { + const { + scheduleID, + duration, + setDuration, + value, + setValue, + showForm, + setShowForm, + shift, + setShift, + } = props + + const classes = useStyles() + const now = useMemo(() => DateTime.utc().startOf('minute').toISO(), []) + const { q, zone, isLocalZone } = useScheduleTZ(scheduleID) + + function validate(): Error | null { + if (isISOAfter(value.start, value.end)) { + return new Error('Start date/time cannot be after end date/time.') + } + return null + } + + return ( + + + + The schedule will be exactly as configured here for the entire + duration (ignoring all assignments and overrides). + + + + + + Times shown in schedule timezone ({zone}) + + + + + validate()} + timeZone={zone} + disabled={q.loading} + hint={isLocalZone ? '' : fmtLocal(value.start)} + /> + + + validate()} + timeZone={zone} + disabled={q.loading} + hint={isLocalZone ? '' : fmtLocal(value.end)} + /> + + + { + setDuration({ ...duration, ...newValue }) + setValue({ + ...value, + shiftDur: Duration.fromObject({ + [newValue.ivl]: newValue.dur, + }), + }) + }} + > + + validate()} + disabled={q.loading} + /> + + + validate()} + disabled={q.loading} + > + Hour + Day + Week + + + + + + + + + + setValue({ ...value, shifts })} + scheduleID={scheduleID} + showForm={showForm} + setShowForm={setShowForm} + shift={shift} + setShift={setShift} + /> + + + ) +} diff --git a/web/src/app/schedules/temp-sched/sharedUtils.tsx b/web/src/app/schedules/temp-sched/sharedUtils.tsx index 5ea6edeb01..aeea309d10 100644 --- a/web/src/app/schedules/temp-sched/sharedUtils.tsx +++ b/web/src/app/schedules/temp-sched/sharedUtils.tsx @@ -5,7 +5,10 @@ export type TempSchedValue = { start: string end: string shifts: Shift[] - shiftDur?: Duration + shiftDur: Duration + + clearStart?: string | null + clearEnd?: string | null } export type Shift = { From c1c2a7e6977803c21438fa4f590e4bf642c41eec Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 08:09:54 -0700 Subject: [PATCH 04/20] get and render associated users on a sched --- .../temp-sched/TempSchedAddNewShift.tsx | 24 ++++++++++++-- .../schedules/temp-sched/TempSchedDialog.tsx | 31 +++++++++++++++++-- .../schedules/temp-sched/TempSchedForm.tsx | 4 +++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx index 69d5c48c53..0294c438ce 100644 --- a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { Button, Checkbox, FormControlLabel, Grid } from '@mui/material' +import { Button, Checkbox, Chip, FormControlLabel, Grid } from '@mui/material' import Typography from '@mui/material/Typography' import ToggleIcon from '@mui/icons-material/CompareArrows' import _ from 'lodash' @@ -14,15 +14,17 @@ import { UserSelect } from '../../selection' import ClickableText from '../../util/ClickableText' import NumberField from '../../util/NumberField' import { fmtLocal } from '../../util/timeFormat' +import { User } from 'web/src/schema' type AddShiftsStepProps = { value: TempSchedValue onChange: (newValue: Shift[]) => void scheduleID: string + associatedUsers: Array showForm: boolean setShowForm: (showForm: boolean) => void - shift: Shift | null + shift: Shift setShift: (shift: Shift) => void } @@ -72,6 +74,7 @@ function mergeShifts(_shifts: Shift[]): Shift[] { export default function TempSchedAddNewShift({ scheduleID, + associatedUsers, onChange, value, shift, @@ -152,6 +155,23 @@ export default function TempSchedAddNewShift({ Add Shift + + + {associatedUsers.map((u) => ( + { + setShift({ + ...shift, + userID: u.id, + }) + }} + /> + ))} + + DateTime.utc().startOf('minute').toISO(), []) const [showForm, setShowForm] = useState(false) + const [{ fetching: fetchingUsers, error: errorUsers, data: dataUsers }] = + useQuery({ + query, + variables: { + id: scheduleID, + }, + }) + const associatedUsers: Array = dataUsers.schedule.associatedUsers + const [{ fetching, error }, commit] = useMutation(mutation) let defaultShiftDur = {} as DurationValues @@ -258,6 +279,11 @@ export default function TempSchedDialog({ .concat(shiftErrors) .concat(noCoverageErrs) + // if error from loading associated users + if (errorUsers?.message) { + errs.concat({ message: errorUsers.message }) + } + return ( { setValue({ ...value, ...ensureInterval(value, newValue) }) @@ -294,6 +320,7 @@ export default function TempSchedDialog({ {/* left pane */} ({ contentText, @@ -31,6 +32,7 @@ const useStyles = makeStyles(() => ({ interface TempSchedFormProps { scheduleID: string + associatedUsers: Array duration: DurationValues setDuration: React.Dispatch> value: TempSchedValue @@ -44,6 +46,7 @@ interface TempSchedFormProps { export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { const { scheduleID, + associatedUsers, duration, setDuration, value, @@ -165,6 +168,7 @@ export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { setValue({ ...value, shifts })} scheduleID={scheduleID} From 3ce33c988a35aa8390397639fe7387dcb45b8641 Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 09:44:26 -0700 Subject: [PATCH 05/20] ux refresh --- .../temp-sched/TempSchedAddNewShift.tsx | 66 ++++++++++++++----- .../schedules/temp-sched/TempSchedDialog.tsx | 9 ++- .../schedules/temp-sched/TempSchedForm.tsx | 24 +++++-- .../schedules/temp-sched/shiftsListUtil.tsx | 15 ++--- 4 files changed, 86 insertions(+), 28 deletions(-) diff --git a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx index 0294c438ce..afe47e3d88 100644 --- a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx @@ -26,6 +26,8 @@ type AddShiftsStepProps = { setShowForm: (showForm: boolean) => void shift: Shift setShift: (shift: Shift) => void + isCustomShiftTimeRange: boolean + setIsCustomShiftTimeRange: (bool: boolean) => void } type DTShift = { @@ -79,10 +81,12 @@ export default function TempSchedAddNewShift({ value, shift, setShift, + isCustomShiftTimeRange, + setIsCustomShiftTimeRange, }: AddShiftsStepProps): JSX.Element { const [submitted, setSubmitted] = useState(false) - const [custom, setCustom] = useState(false) + // const [custom, setCustom] = useState(false) const [manualEntry, setManualEntry] = useState(true) const { q, zone, isLocalZone } = useScheduleTZ(scheduleID) @@ -141,7 +145,7 @@ export default function TempSchedAddNewShift({ start: shift.end, end: end.plus(value.shiftDur as Duration).toISO(), }) - setCustom(false) + setIsCustomShiftTimeRange(false) setSubmitted(false) } @@ -153,7 +157,11 @@ export default function TempSchedAddNewShift({ > - Add Shift + Add Shift + + Showing all users assigned to this schedule. Select a user to add to + the next shift. + @@ -176,19 +184,46 @@ export default function TempSchedAddNewShift({ - + + + } + label={ + + Add user to next shift + + } + onChange={() => setIsCustomShiftTimeRange(false)} + /> + + } + control={ + + } label={ - - Configure custom shift + + Add user to custom time range } - onChange={() => setCustom(!custom)} + onChange={() => setIsCustomShiftTimeRange(true)} /> @@ -214,7 +249,7 @@ export default function TempSchedAddNewShift({ return value }} timeZone={zone} - disabled={q.loading || !custom} + disabled={q.loading || !isCustomShiftTimeRange} hint={isLocalZone ? '' : fmtLocal(value?.start)} /> @@ -231,7 +266,7 @@ export default function TempSchedAddNewShift({ .plus({ year: 1 }) .toISO()} hint={ - custom ? ( + isCustomShiftTimeRange ? ( {!isLocalZone && fmtLocal(value?.end)}
@@ -247,7 +282,7 @@ export default function TempSchedAddNewShift({ ) : null } timeZone={zone} - disabled={q.loading || !custom} + disabled={q.loading || !isCustomShiftTimeRange} /> ) : ( setManualEntry(true)} @@ -295,12 +330,13 @@ export default function TempSchedAddNewShift({ diff --git a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx index 3cd5a42c60..1aabff368c 100644 --- a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx @@ -48,6 +48,9 @@ function shiftEquals(a: Shift, b: Shift): boolean { const useStyles = makeStyles((theme: Theme) => ({ formContainer: { height: '100%', + marginTop: '-16px', + paddingLeft: '.5rem', + paddingRight: '.5rem', }, noCoverageError: { marginTop: '.5rem', @@ -58,7 +61,7 @@ const useStyles = makeStyles((theme: Theme) => ({ marginTop: '1rem', }, [theme.breakpoints.up('md')]: { - paddingLeft: '1rem', + paddingLeft: '4rem', }, overflow: 'hidden', }, @@ -97,6 +100,7 @@ export default function TempSchedDialog({ const { q, zone } = useScheduleTZ(scheduleID) const now = useMemo(() => DateTime.utc().startOf('minute').toISO(), []) const [showForm, setShowForm] = useState(false) + const [isCustomShiftTimeRange, setIsCustomShiftTimeRange] = useState(false) const [{ fetching: fetchingUsers, error: errorUsers, data: dataUsers }] = useQuery({ @@ -185,6 +189,7 @@ export default function TempSchedDialog({ const nextStart = coverageGap?.start const nextEnd = nextStart.plus(value.shiftDur) + setIsCustomShiftTimeRange(true) setShift({ userID: shift?.userID ?? '', truncated: !!shift?.truncated, @@ -329,6 +334,8 @@ export default function TempSchedDialog({ setShowForm={setShowForm} shift={shift} setShift={setShift} + isCustomShiftTimeRange={isCustomShiftTimeRange} + setIsCustomShiftTimeRange={setIsCustomShiftTimeRange} /> {/* right pane */} diff --git a/web/src/app/schedules/temp-sched/TempSchedForm.tsx b/web/src/app/schedules/temp-sched/TempSchedForm.tsx index 38ce46e4a8..6647fc0841 100644 --- a/web/src/app/schedules/temp-sched/TempSchedForm.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedForm.tsx @@ -41,6 +41,8 @@ interface TempSchedFormProps { setShowForm: React.Dispatch> shift: Shift setShift: React.Dispatch> + isCustomShiftTimeRange: boolean + setIsCustomShiftTimeRange: (bool: boolean) => void } export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { @@ -55,6 +57,8 @@ export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { setShowForm, shift, setShift, + isCustomShiftTimeRange, + setIsCustomShiftTimeRange, } = props const classes = useStyles() @@ -71,14 +75,22 @@ export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { return ( - - The schedule will be exactly as configured here for the entire - duration (ignoring all assignments and overrides). + + Once submitted, the schedule will be exactly as configured here for + the entire duration. All overrides and current assignments will be + ignored. - + Times shown in schedule timezone ({zone}) @@ -166,6 +178,8 @@ export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { + + diff --git a/web/src/app/schedules/temp-sched/shiftsListUtil.tsx b/web/src/app/schedules/temp-sched/shiftsListUtil.tsx index f04365e257..292620c0e5 100644 --- a/web/src/app/schedules/temp-sched/shiftsListUtil.tsx +++ b/web/src/app/schedules/temp-sched/shiftsListUtil.tsx @@ -10,9 +10,8 @@ import { import { ExplicitZone, splitShift } from '../../util/luxon-helpers' import { parseInterval } from '../../util/shifts' import { Shift } from './sharedUtils' -import { Tooltip } from '@mui/material' +import { Tooltip, Typography } from '@mui/material' import { fmtLocal, fmtTime } from '../../util/timeFormat' -import { InfoOutlined } from '@mui/icons-material' export type Sortable = T & { // at is the earliest point in time for a list item @@ -167,12 +166,12 @@ export function getCoverageGapItems( message: '', details: ( - {details} - - ), - action: ( - - +
+ {details} + + Click to use this custom time range for the next shift + +
), at: gap.start, From 94a35ef56a60e42c370cb4daafc9548a89db12f9 Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 11:33:38 -0700 Subject: [PATCH 06/20] add color to chips --- .../temp-sched/TempSchedAddNewShift.tsx | 72 +++++++++++++++---- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx index afe47e3d88..f08c06eec3 100644 --- a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx @@ -1,5 +1,12 @@ import React, { useEffect, useState } from 'react' -import { Button, Checkbox, Chip, FormControlLabel, Grid } from '@mui/material' +import { + Avatar, + Button, + Checkbox, + Chip, + FormControlLabel, + Grid, +} from '@mui/material' import Typography from '@mui/material/Typography' import ToggleIcon from '@mui/icons-material/CompareArrows' import _ from 'lodash' @@ -15,6 +22,7 @@ import ClickableText from '../../util/ClickableText' import NumberField from '../../util/NumberField' import { fmtLocal } from '../../util/timeFormat' import { User } from 'web/src/schema' +import { green } from '@mui/material/colors' type AddShiftsStepProps = { value: TempSchedValue @@ -149,6 +157,21 @@ export default function TempSchedAddNewShift({ setSubmitted(false) } + function getUserIDCountInValue(userID: string): number { + const uIDs = value.shifts.map((s) => s.userID) + const count = uIDs.filter((id) => id === userID).length + return count + } + + function getChipColor( + userID: string, + count: number, + ): 'primary' | 'success' | 'default' { + if (shift.userID === userID) return 'primary' + if (count > 0) return 'success' + return 'default' + } + return ( - - {associatedUsers.map((u) => ( - { - setShift({ - ...shift, - userID: u.id, - }) - }} - /> - ))} + + {associatedUsers.map((u) => { + const count = getUserIDCountInValue(u.id) + + return ( + { + setShift({ + ...shift, + userID: u.id, + }) + }} + icon={ + count > 0 ? ( + + {count} + + ) : undefined + } + /> + ) + })} From 2e90435d4258159195e7b79406ad400bcb6d465b Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 11:35:50 -0700 Subject: [PATCH 07/20] add icon to add shift button --- web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx index f08c06eec3..199897dd48 100644 --- a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx @@ -23,6 +23,7 @@ import NumberField from '../../util/NumberField' import { fmtLocal } from '../../util/timeFormat' import { User } from 'web/src/schema' import { green } from '@mui/material/colors' +import { ArrowRight } from 'mdi-material-ui' type AddShiftsStepProps = { value: TempSchedValue @@ -377,6 +378,7 @@ export default function TempSchedAddNewShift({ color='secondary' variant='contained' onClick={handleAddShift} + endIcon={} > Add Next Shift From 7970de0a5c09c35ea3cabd54dc4fe7667e968e74 Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 12:33:06 -0700 Subject: [PATCH 08/20] add sort icons --- .../temp-sched/TempSchedAddNewShift.tsx | 90 ++++++++++++++++++- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx index 199897dd48..cee8c7aa44 100644 --- a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx @@ -6,6 +6,9 @@ import { Chip, FormControlLabel, Grid, + Popover, + IconButton, + ButtonGroup, } from '@mui/material' import Typography from '@mui/material/Typography' import ToggleIcon from '@mui/icons-material/CompareArrows' @@ -23,7 +26,12 @@ import NumberField from '../../util/NumberField' import { fmtLocal } from '../../util/timeFormat' import { User } from 'web/src/schema' import { green } from '@mui/material/colors' -import { ArrowRight } from 'mdi-material-ui' +import { + ArrowRight, + ShuffleVariant, + SortAlphabeticalAscending, + SortAlphabeticalDescending, +} from 'mdi-material-ui' type AddShiftsStepProps = { value: TempSchedValue @@ -39,6 +47,8 @@ type AddShiftsStepProps = { setIsCustomShiftTimeRange: (bool: boolean) => void } +type SortType = 'A-Z' | 'Z-A' | 'RAND' + type DTShift = { userID: string span: Interval @@ -95,10 +105,25 @@ export default function TempSchedAddNewShift({ }: AddShiftsStepProps): JSX.Element { const [submitted, setSubmitted] = useState(false) - // const [custom, setCustom] = useState(false) const [manualEntry, setManualEntry] = useState(true) const { q, zone, isLocalZone } = useScheduleTZ(scheduleID) + const [sortType, setSortType] = useState('A-Z') + const [sortTypeAnchor, setSortTypeAnchor] = + useState(null) + const sortPopoverOpen = Boolean(sortTypeAnchor) + const sortTypeID = sortPopoverOpen ? 'sort-type-select' : undefined + + const handleFilterTypeClick = ( + event: React.MouseEvent, + ): void => { + setSortTypeAnchor(event.currentTarget) + } + + const handleFilterTypeClose = (): void => { + setSortTypeAnchor(null) + } + // set start equal to the temporary schedule's start // can't this do on mount since the step renderer puts everyone on the DOM at once useEffect(() => { @@ -173,6 +198,11 @@ export default function TempSchedAddNewShift({ return 'default' } + function handleSetSortType(sortType: SortType): void { + setSortType(sortType) + setSortTypeAnchor(null) + } + return ( setShift(val)} > - + Add Shift + + {sortType === 'A-Z' && ( + + )} + {sortType === 'Z-A' && ( + + )} + {sortType === 'RAND' && } + + + + + + + + + + Showing all users assigned to this schedule. Select a user to add to the next shift. From 6928cf81d9813a7c73211ff3265fc719c89446c7 Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 12:46:02 -0700 Subject: [PATCH 09/20] add sort functionality --- .../temp-sched/TempSchedAddNewShift.tsx | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx index cee8c7aa44..4711847847 100644 --- a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Avatar, Button, @@ -32,6 +32,8 @@ import { SortAlphabeticalAscending, SortAlphabeticalDescending, } from 'mdi-material-ui' +import Chance from 'chance' +const c = new Chance() type AddShiftsStepProps = { value: TempSchedValue @@ -203,6 +205,34 @@ export default function TempSchedAddNewShift({ setSortTypeAnchor(null) } + function sortFn(_a: User, _b: User): number { + const a = _a.name + const b = _b.name + + if (sortType === 'A-Z') { + if (a > b) return 1 + if (a < b) return -1 + return 0 + } + + if (sortType === 'Z-A') { + if (a < b) return 1 + if (a > b) return -1 + return 0 + } + + if (sortType === 'RAND') { + return c.pickone([1, -1, 0]) + } + + return 0 + } + + const users = useMemo( + () => associatedUsers.sort(sortFn), + [associatedUsers, sortType], + ) + return ( - {associatedUsers.map((u) => { + {users.map((u) => { const count = getUserIDCountInValue(u.id) return ( From fb25ef36ec237a382ea089d9bdac362259b7ef6b Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 12:46:10 -0700 Subject: [PATCH 10/20] fix caption color --- .../app/schedules/temp-sched/TempSchedForm.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/web/src/app/schedules/temp-sched/TempSchedForm.tsx b/web/src/app/schedules/temp-sched/TempSchedForm.tsx index 6647fc0841..acbf57a532 100644 --- a/web/src/app/schedules/temp-sched/TempSchedForm.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedForm.tsx @@ -1,12 +1,5 @@ import React, { useMemo } from 'react' -import { - Grid, - DialogContentText, - Typography, - TextField, - MenuItem, - Divider, -} from '@mui/material' +import { Grid, Typography, TextField, MenuItem, Divider } from '@mui/material' import makeStyles from '@mui/styles/makeStyles' import { DateTime, Duration } from 'luxon' import { FormField, FormContainer } from '../../forms' @@ -75,14 +68,11 @@ export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { return ( - + Once submitted, the schedule will be exactly as configured here for the entire duration. All overrides and current assignments will be ignored. - + From 8d2c301e791128ad10d3fd430ccb9b3cad498bef Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 12:50:53 -0700 Subject: [PATCH 11/20] fix formdialog subtitle bottom padding --- web/src/app/dialogs/components/DialogTitleWrapper.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/app/dialogs/components/DialogTitleWrapper.jsx b/web/src/app/dialogs/components/DialogTitleWrapper.jsx index 3d870dbab6..4f642d46ea 100644 --- a/web/src/app/dialogs/components/DialogTitleWrapper.jsx +++ b/web/src/app/dialogs/components/DialogTitleWrapper.jsx @@ -18,6 +18,7 @@ const useStyles = makeStyles((theme) => { subtitle: { overflowY: 'unset', flexGrow: 0, + paddingBottom: 0, }, topRightActions, } From fab15e71c54b9490bd00260ecb6acaee5a0dc90b Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 13:03:34 -0700 Subject: [PATCH 12/20] fix usememo --- .../schedules/temp-sched/TempSchedAddNewShift.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx index 4711847847..a020c3190e 100644 --- a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx @@ -9,6 +9,7 @@ import { Popover, IconButton, ButtonGroup, + useTheme, } from '@mui/material' import Typography from '@mui/material/Typography' import ToggleIcon from '@mui/icons-material/CompareArrows' @@ -25,7 +26,6 @@ import ClickableText from '../../util/ClickableText' import NumberField from '../../util/NumberField' import { fmtLocal } from '../../util/timeFormat' import { User } from 'web/src/schema' -import { green } from '@mui/material/colors' import { ArrowRight, ShuffleVariant, @@ -105,6 +105,7 @@ export default function TempSchedAddNewShift({ isCustomShiftTimeRange, setIsCustomShiftTimeRange, }: AddShiftsStepProps): JSX.Element { + const theme = useTheme() const [submitted, setSubmitted] = useState(false) const [manualEntry, setManualEntry] = useState(true) @@ -228,10 +229,7 @@ export default function TempSchedAddNewShift({ return 0 } - const users = useMemo( - () => associatedUsers.sort(sortFn), - [associatedUsers, sortType], - ) + const users = useMemo(() => associatedUsers.sort(sortFn), [sortType]) return ( {count} From 0f9d8e634d21559976bed5049783924f447a02d8 Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 15:19:28 -0700 Subject: [PATCH 13/20] add sort by last pick order --- graphql2/generated.go | 243 ++++++++++++++++++ graphql2/graph/_Mutation.graphqls | 1 + graphql2/graphqlapp/schedule.go | 16 ++ graphql2/models_gen.go | 5 + graphql2/schema.graphql | 6 + schedule/store.go | 63 +++++ .../temp-sched/TempSchedAddNewShift.tsx | 53 +++- .../schedules/temp-sched/TempSchedDialog.tsx | 24 ++ .../schedules/temp-sched/TempSchedForm.tsx | 6 + .../app/schedules/temp-sched/sharedUtils.tsx | 26 ++ web/src/schema.d.ts | 7 + 11 files changed, 447 insertions(+), 3 deletions(-) diff --git a/graphql2/generated.go b/graphql2/generated.go index ab075dc877..8f5d8d05fc 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -506,6 +506,7 @@ type ComplexityRoot struct { SetScheduleOnCallNotificationRules func(childComplexity int, input SetScheduleOnCallNotificationRulesInput) int SetSystemLimits func(childComplexity int, input []SystemLimitInput) int SetTemporarySchedule func(childComplexity int, input SetTemporaryScheduleInput) int + SetTemporarySchedulePickOrder func(childComplexity int, input SetTempSchedPickOrderInput) int SwoAction func(childComplexity int, action SWOAction) int TestContactMethod func(childComplexity int, id string) int UpdateAlerts func(childComplexity int, input UpdateAlertsInput) int @@ -687,6 +688,7 @@ type ComplexityRoot struct { Description func(childComplexity int) int ID func(childComplexity int) int IsFavorite func(childComplexity int) int + LastTempSchedPickOrder func(childComplexity int) int Name func(childComplexity int) int OnCallNotificationRules func(childComplexity int) int Shifts func(childComplexity int, start time.Time, end time.Time, userIDs []string) int @@ -962,6 +964,7 @@ type MutationResolver interface { LinkAccount(ctx context.Context, token string) (bool, error) ReEncryptKeyringsAndConfig(ctx context.Context) (bool, error) SetTemporarySchedule(ctx context.Context, input SetTemporaryScheduleInput) (bool, error) + SetTemporarySchedulePickOrder(ctx context.Context, input SetTempSchedPickOrderInput) (bool, error) ClearTemporarySchedules(ctx context.Context, input ClearTemporarySchedulesInput) (bool, error) SetScheduleOnCallNotificationRules(ctx context.Context, input SetScheduleOnCallNotificationRulesInput) (bool, error) DebugCarrierInfo(ctx context.Context, input DebugCarrierInfoInput) (*twilio.CarrierInfo, error) @@ -1090,6 +1093,7 @@ type ScheduleResolver interface { AssignedTo(ctx context.Context, obj *schedule.Schedule) ([]assignment.RawTarget, error) Shifts(ctx context.Context, obj *schedule.Schedule, start time.Time, end time.Time, userIDs []string) ([]oncall.Shift, error) AssociatedUsers(ctx context.Context, obj *schedule.Schedule) ([]user.User, error) + LastTempSchedPickOrder(ctx context.Context, obj *schedule.Schedule) ([]string, error) Targets(ctx context.Context, obj *schedule.Schedule) ([]ScheduleTarget, error) Target(ctx context.Context, obj *schedule.Schedule, input assignment.RawTarget) (*ScheduleTarget, error) IsFavorite(ctx context.Context, obj *schedule.Schedule) (bool, error) @@ -3146,6 +3150,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.SetTemporarySchedule(childComplexity, args["input"].(SetTemporaryScheduleInput)), true + case "Mutation.setTemporarySchedulePickOrder": + if e.complexity.Mutation.SetTemporarySchedulePickOrder == nil { + break + } + + args, err := ec.field_Mutation_setTemporarySchedulePickOrder_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SetTemporarySchedulePickOrder(childComplexity, args["input"].(SetTempSchedPickOrderInput)), true + case "Mutation.swoAction": if e.complexity.Mutation.SwoAction == nil { break @@ -4408,6 +4424,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Schedule.IsFavorite(childComplexity), true + case "Schedule.lastTempSchedPickOrder": + if e.complexity.Schedule.LastTempSchedPickOrder == nil { + break + } + + return e.complexity.Schedule.LastTempSchedPickOrder(childComplexity), true + case "Schedule.name": if e.complexity.Schedule.Name == nil { break @@ -5350,6 +5373,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputSetLabelInput, ec.unmarshalInputSetScheduleOnCallNotificationRulesInput, ec.unmarshalInputSetScheduleShiftInput, + ec.unmarshalInputSetTempSchedPickOrderInput, ec.unmarshalInputSetTemporaryScheduleInput, ec.unmarshalInputSlackChannelSearchOptions, ec.unmarshalInputSlackUserGroupSearchOptions, @@ -6678,6 +6702,34 @@ func (ec *executionContext) field_Mutation_setSystemLimits_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_setTemporarySchedulePickOrder_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_setTemporarySchedulePickOrder_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_setTemporarySchedulePickOrder_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (SetTempSchedPickOrderInput, error) { + if _, ok := rawArgs["input"]; !ok { + var zeroVal SetTempSchedPickOrderInput + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNSetTempSchedPickOrderInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSetTempSchedPickOrderInput(ctx, tmp) + } + + var zeroVal SetTempSchedPickOrderInput + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_setTemporarySchedule_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -18944,6 +18996,61 @@ func (ec *executionContext) fieldContext_Mutation_setTemporarySchedule(ctx conte return fc, nil } +func (ec *executionContext) _Mutation_setTemporarySchedulePickOrder(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_setTemporarySchedulePickOrder(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().SetTemporarySchedulePickOrder(rctx, fc.Args["input"].(SetTempSchedPickOrderInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_setTemporarySchedulePickOrder(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_setTemporarySchedulePickOrder_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_clearTemporarySchedules(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_clearTemporarySchedules(ctx, field) if err != nil { @@ -20678,6 +20785,8 @@ func (ec *executionContext) fieldContext_Mutation_createSchedule(ctx context.Con return ec.fieldContext_Schedule_shifts(ctx, field) case "associatedUsers": return ec.fieldContext_Schedule_associatedUsers(ctx, field) + case "lastTempSchedPickOrder": + return ec.fieldContext_Schedule_lastTempSchedPickOrder(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -24773,6 +24882,8 @@ func (ec *executionContext) fieldContext_Query_schedule(ctx context.Context, fie return ec.fieldContext_Schedule_shifts(ctx, field) case "associatedUsers": return ec.fieldContext_Schedule_associatedUsers(ctx, field) + case "lastTempSchedPickOrder": + return ec.fieldContext_Schedule_lastTempSchedPickOrder(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -28899,6 +29010,50 @@ func (ec *executionContext) fieldContext_Schedule_associatedUsers(_ context.Cont return fc, nil } +func (ec *executionContext) _Schedule_lastTempSchedPickOrder(ctx context.Context, field graphql.CollectedField, obj *schedule.Schedule) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Schedule_lastTempSchedPickOrder(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Schedule().LastTempSchedPickOrder(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]string) + fc.Result = res + return ec.marshalNID2ᚕstringᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Schedule_lastTempSchedPickOrder(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Schedule", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Schedule_targets(ctx context.Context, field graphql.CollectedField, obj *schedule.Schedule) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Schedule_targets(ctx, field) if err != nil { @@ -29216,6 +29371,8 @@ func (ec *executionContext) fieldContext_ScheduleConnection_nodes(_ context.Cont return ec.fieldContext_Schedule_shifts(ctx, field) case "associatedUsers": return ec.fieldContext_Schedule_associatedUsers(ctx, field) + case "lastTempSchedPickOrder": + return ec.fieldContext_Schedule_lastTempSchedPickOrder(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -32779,6 +32936,8 @@ func (ec *executionContext) fieldContext_User_assignedSchedules(_ context.Contex return ec.fieldContext_Schedule_shifts(ctx, field) case "associatedUsers": return ec.fieldContext_Schedule_associatedUsers(ctx, field) + case "lastTempSchedPickOrder": + return ec.fieldContext_Schedule_lastTempSchedPickOrder(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -33066,6 +33225,8 @@ func (ec *executionContext) fieldContext_UserCalendarSubscription_schedule(_ con return ec.fieldContext_Schedule_shifts(ctx, field) case "associatedUsers": return ec.fieldContext_Schedule_associatedUsers(ctx, field) + case "lastTempSchedPickOrder": + return ec.fieldContext_Schedule_lastTempSchedPickOrder(ctx, field) case "targets": return ec.fieldContext_Schedule_targets(ctx, field) case "target": @@ -39755,6 +39916,40 @@ func (ec *executionContext) unmarshalInputSetScheduleShiftInput(ctx context.Cont return it, nil } +func (ec *executionContext) unmarshalInputSetTempSchedPickOrderInput(ctx context.Context, obj any) (SetTempSchedPickOrderInput, error) { + var it SetTempSchedPickOrderInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"scheduleID", "userIDs"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "scheduleID": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("scheduleID")) + data, err := ec.unmarshalNID2string(ctx, v) + if err != nil { + return it, err + } + it.ScheduleID = data + case "userIDs": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("userIDs")) + data, err := ec.unmarshalNID2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.UserIDs = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputSetTemporaryScheduleInput(ctx context.Context, obj any) (SetTemporaryScheduleInput, error) { var it SetTemporaryScheduleInput asMap := map[string]any{} @@ -45012,6 +45207,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "setTemporarySchedulePickOrder": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_setTemporarySchedulePickOrder(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "clearTemporarySchedules": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_clearTemporarySchedules(ctx, field) @@ -47648,6 +47850,42 @@ func (ec *executionContext) _Schedule(ctx context.Context, sel ast.SelectionSet, continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "lastTempSchedPickOrder": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Schedule_lastTempSchedPickOrder(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "targets": field := field @@ -53552,6 +53790,11 @@ func (ec *executionContext) unmarshalNSetScheduleShiftInput2ᚕgithubᚗcomᚋta return res, nil } +func (ec *executionContext) unmarshalNSetTempSchedPickOrderInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSetTempSchedPickOrderInput(ctx context.Context, v any) (SetTempSchedPickOrderInput, error) { + res, err := ec.unmarshalInputSetTempSchedPickOrderInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNSetTemporaryScheduleInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSetTemporaryScheduleInput(ctx context.Context, v any) (SetTemporaryScheduleInput, error) { res, err := ec.unmarshalInputSetTemporaryScheduleInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/graphql2/graph/_Mutation.graphqls b/graphql2/graph/_Mutation.graphqls index 0d614e3321..3bdb0080d6 100644 --- a/graphql2/graph/_Mutation.graphqls +++ b/graphql2/graph/_Mutation.graphqls @@ -10,6 +10,7 @@ type Mutation { reEncryptKeyringsAndConfig: Boolean! setTemporarySchedule(input: SetTemporaryScheduleInput!): Boolean! + setTemporarySchedulePickOrder(input: SetTempSchedPickOrderInput!): Boolean! clearTemporarySchedules(input: ClearTemporarySchedulesInput!): Boolean! setScheduleOnCallNotificationRules( diff --git a/graphql2/graphqlapp/schedule.go b/graphql2/graphqlapp/schedule.go index c235602ef7..674bb5a66d 100644 --- a/graphql2/graphqlapp/schedule.go +++ b/graphql2/graphqlapp/schedule.go @@ -185,6 +185,22 @@ func (s *Schedule) AssociatedUsers(ctx context.Context, raw *schedule.Schedule) return users, nil } +func (s *Schedule) LastTempSchedPickOrder(ctx context.Context, raw *schedule.Schedule) ([]string, error) { + userIDs, err := s.ScheduleStore.FindLastTempSchedPickOrder(ctx, raw.ID) + if err != nil { + return nil, err + } + return userIDs, nil +} + +func (m *Mutation) SetTemporarySchedulePickOrder(ctx context.Context, input graphql2.SetTempSchedPickOrderInput) (ok bool, err error) { + ok, err = m.ScheduleStore.SetTempSchedPickOrder(ctx, input.ScheduleID, input.UserIDs) + if err != nil { + return false, err + } + return true, nil +} + func (s *Schedule) AssignedTo(ctx context.Context, raw *schedule.Schedule) ([]assignment.RawTarget, error) { pols, err := s.PolicyStore.FindAllPoliciesBySchedule(ctx, raw.ID) if err != nil { diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index 17f13a7a22..3f5594a09d 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -731,6 +731,11 @@ type SetScheduleOnCallNotificationRulesInput struct { Rules []OnCallNotificationRuleInput `json:"rules"` } +type SetTempSchedPickOrderInput struct { + ScheduleID string `json:"scheduleID"` + UserIDs []string `json:"userIDs"` +} + type SetTemporaryScheduleInput struct { ScheduleID string `json:"scheduleID"` ClearStart *time.Time `json:"clearStart,omitempty"` diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index 571532976f..65cb82b8df 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -693,6 +693,7 @@ type Schedule { ): [OnCallShift!]! associatedUsers: [User!]! + lastTempSchedPickOrder: [ID!]! targets: [ScheduleTarget!]! target(input: TargetInput!): ScheduleTarget @@ -702,6 +703,11 @@ type Schedule { onCallNotificationRules: [OnCallNotificationRule!]! } +input SetTempSchedPickOrderInput { + scheduleID: ID! + userIDs: [ID!]! +} + input SetScheduleOnCallNotificationRulesInput { scheduleID: ID! rules: [OnCallNotificationRuleInput!]! diff --git a/schedule/store.go b/schedule/store.go index ab4db6b33f..0183ba0d6b 100644 --- a/schedule/store.go +++ b/schedule/store.go @@ -3,6 +3,7 @@ package schedule import ( "context" "database/sql" + "encoding/json" "github.com/google/uuid" "github.com/pkg/errors" @@ -34,6 +35,9 @@ type Store struct { findAssociatedUserIDs *sql.Stmt + findLastTempSchedPickOrder *sql.Stmt + setTempSchedPickOrder *sql.Stmt + usr *user.Store } @@ -87,6 +91,26 @@ func NewStore(ctx context.Context, db *sql.DB, usr *user.Store) (*Store, error) LEFT JOIN rotation_participants r ON r.rotation_id = s.tgt_rotation_id WHERE s.schedule_id = $1 `), + + findLastTempSchedPickOrder: p.P(` + SELECT data -> 'user_ids' AS user_ids + FROM schedule_data + WHERE schedule_id = $1; + `), + setTempSchedPickOrder: p.P(` + INSERT INTO schedule_data (schedule_id, data) + VALUES ( + $1, + jsonb_build_object('user_ids', to_jsonb($2::text[])) + ) + ON CONFLICT (schedule_id) + DO UPDATE SET data = jsonb_set( + COALESCE(schedule_data.data, '{}'), + '{user_ids}', + to_jsonb($2::text[]), + true + ); + `), }, p.Err } @@ -361,3 +385,42 @@ func (store *Store) FindAssociatedUserIDs(ctx context.Context, id string) ([]str return userIDs, nil } + +func (store *Store) FindLastTempSchedPickOrder(ctx context.Context, scheduleID string) ([]string, error) { + err := validate.UUID("ScheduleID", scheduleID) + if err != nil { + return nil, err + } + + var userIDs []string + var rawUserIDs []byte + row := store.findLastTempSchedPickOrder.QueryRowContext(ctx, scheduleID) + err = row.Scan(&rawUserIDs) + if errors.Is(err, sql.ErrNoRows) { + return userIDs, nil + } + if err != nil { + return nil, err + } + + err = json.Unmarshal(rawUserIDs, &userIDs) + if err != nil { + return nil, err + } + + return userIDs, nil +} + +func (store *Store) SetTempSchedPickOrder(ctx context.Context, scheduleID string, userIDs []string) (bool, error) { + err := validate.UUID("ScheduleID", scheduleID) + if err != nil { + return false, err + } + + _, err = store.setTempSchedPickOrder.ExecContext(ctx, scheduleID, userIDs) + if err != nil { + return false, err + } + + return true, nil +} diff --git a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx index a020c3190e..6cec921b5e 100644 --- a/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedAddNewShift.tsx @@ -14,7 +14,12 @@ import { import Typography from '@mui/material/Typography' import ToggleIcon from '@mui/icons-material/CompareArrows' import _ from 'lodash' -import { dtToDuration, Shift, TempSchedValue } from './sharedUtils' +import { + dtToDuration, + Shift, + sortUsersByLastPickOrder, + TempSchedValue, +} from './sharedUtils' import { FormContainer, FormField } from '../../forms' import { DateTime, Duration, Interval } from 'luxon' import { FieldError } from '../../util/errutil' @@ -29,12 +34,22 @@ import { User } from 'web/src/schema' import { ArrowRight, ShuffleVariant, + Sort, SortAlphabeticalAscending, SortAlphabeticalDescending, } from 'mdi-material-ui' import Chance from 'chance' +import { gql, useQuery } from 'urql' const c = new Chance() +const query = gql` + query ($id: ID!) { + schedule(id: $id) { + lastTempSchedPickOrder + } + } +` + type AddShiftsStepProps = { value: TempSchedValue onChange: (newValue: Shift[]) => void @@ -47,9 +62,11 @@ type AddShiftsStepProps = { setShift: (shift: Shift) => void isCustomShiftTimeRange: boolean setIsCustomShiftTimeRange: (bool: boolean) => void + pickOrder: string[] + setPickOrder: (pickOrder: string[]) => void } -type SortType = 'A-Z' | 'Z-A' | 'RAND' +type SortType = 'A-Z' | 'Z-A' | 'RAND' | 'LAST-PICKS' type DTShift = { userID: string @@ -104,6 +121,8 @@ export default function TempSchedAddNewShift({ setShift, isCustomShiftTimeRange, setIsCustomShiftTimeRange, + pickOrder, + setPickOrder, }: AddShiftsStepProps): JSX.Element { const theme = useTheme() const [submitted, setSubmitted] = useState(false) @@ -117,6 +136,15 @@ export default function TempSchedAddNewShift({ const sortPopoverOpen = Boolean(sortTypeAnchor) const sortTypeID = sortPopoverOpen ? 'sort-type-select' : undefined + const [{ fetching, error, data }] = useQuery({ + query, + variables: { + id: scheduleID, + }, + }) + const lastTempSchedPickOrder: Array = + data.schedule.lastTempSchedPickOrder + const handleFilterTypeClick = ( event: React.MouseEvent, ): void => { @@ -174,6 +202,10 @@ export default function TempSchedAddNewShift({ } if (!shift) return // ts sanity check + if (!pickOrder.includes(shift.userID)) { + setPickOrder([...pickOrder, shift.userID]) + } + onChange(mergeShifts(value.shifts.concat(shift))) const end = DateTime.fromISO(shift.end, { zone }) setShift({ @@ -226,6 +258,10 @@ export default function TempSchedAddNewShift({ return c.pickone([1, -1, 0]) } + if (sortType === 'LAST-PICKS') { + return sortUsersByLastPickOrder(_a, _b, lastTempSchedPickOrder) + } + return 0 } @@ -254,6 +290,9 @@ export default function TempSchedAddNewShift({ )} {sortType === 'RAND' && } + {sortType === 'LAST-PICKS' && !fetching && !error && ( + + )} } sx={{ justifyContent: 'start', pl: 2, pr: 2 }} > - Sort Randomly + Shuffle + + diff --git a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx index 1aabff368c..897af1cd1a 100644 --- a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx @@ -30,6 +30,12 @@ const mutation = gql` } ` +const pickOrderMutation = gql` + mutation ($input: SetTempSchedPickOrderInput!) { + setTemporarySchedulePickOrder(input: $input) + } +` + const query = gql` query ($id: ID!) { schedule(id: $id) { @@ -101,6 +107,7 @@ export default function TempSchedDialog({ const now = useMemo(() => DateTime.utc().startOf('minute').toISO(), []) const [showForm, setShowForm] = useState(false) const [isCustomShiftTimeRange, setIsCustomShiftTimeRange] = useState(false) + const [pickOrder, setPickOrder] = useState>([]) const [{ fetching: fetchingUsers, error: errorUsers, data: dataUsers }] = useQuery({ @@ -112,6 +119,7 @@ export default function TempSchedDialog({ const associatedUsers: Array = dataUsers.schedule.associatedUsers const [{ fetching, error }, commit] = useMutation(mutation) + const [, commitPickOrder] = useMutation(pickOrderMutation) let defaultShiftDur = {} as DurationValues @@ -262,6 +270,13 @@ export default function TempSchedDialog({ }, { additionalTypenames: ['Schedule'] }, ).then((result) => { + commitPickOrder({ + input: { + scheduleID, + userIDs: pickOrder, + }, + }) + if (!result.error) { onClose() } @@ -336,6 +351,8 @@ export default function TempSchedDialog({ setShift={setShift} isCustomShiftTimeRange={isCustomShiftTimeRange} setIsCustomShiftTimeRange={setIsCustomShiftTimeRange} + pickOrder={pickOrder} + setPickOrder={setPickOrder} /> {/* right pane */} @@ -426,6 +443,13 @@ export default function TempSchedDialog({ (s) => !shiftEquals(shift, s), ), }) + + let order = pickOrder.slice() + const i = order.indexOf(shift.userID) + if (i !== -1) { + order.splice(i, 1) + setPickOrder(order) + } }} edit={edit} handleCoverageGapClick={handleCoverageGapClick} diff --git a/web/src/app/schedules/temp-sched/TempSchedForm.tsx b/web/src/app/schedules/temp-sched/TempSchedForm.tsx index acbf57a532..e30d4d6edb 100644 --- a/web/src/app/schedules/temp-sched/TempSchedForm.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedForm.tsx @@ -36,6 +36,8 @@ interface TempSchedFormProps { setShift: React.Dispatch> isCustomShiftTimeRange: boolean setIsCustomShiftTimeRange: (bool: boolean) => void + pickOrder: string[] + setPickOrder: (pickOrder: string[]) => void } export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { @@ -52,6 +54,8 @@ export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { setShift, isCustomShiftTimeRange, setIsCustomShiftTimeRange, + pickOrder, + setPickOrder, } = props const classes = useStyles() @@ -182,6 +186,8 @@ export default function TempSchedForm(props: TempSchedFormProps): JSX.Element { setShift={setShift} isCustomShiftTimeRange={isCustomShiftTimeRange} setIsCustomShiftTimeRange={setIsCustomShiftTimeRange} + pickOrder={pickOrder} + setPickOrder={setPickOrder} /> diff --git a/web/src/app/schedules/temp-sched/sharedUtils.tsx b/web/src/app/schedules/temp-sched/sharedUtils.tsx index aeea309d10..379073dbe5 100644 --- a/web/src/app/schedules/temp-sched/sharedUtils.tsx +++ b/web/src/app/schedules/temp-sched/sharedUtils.tsx @@ -1,5 +1,6 @@ import { DateTime, Duration, Interval } from 'luxon' import React, { ReactNode } from 'react' +import { User } from 'web/src/schema' export type TempSchedValue = { start: string @@ -108,3 +109,28 @@ export function dtToDuration(a: DateTime, b: DateTime): number { if (!a.isValid || !b.isValid) return -1 return b.diff(a, 'hours').hours } + +export function sortUsersByLastPickOrder( + a: User, + b: User, + lastTempSchedPickOrder: string[], +): number { + const aIndex = lastTempSchedPickOrder.indexOf(a.id) + const bIndex = lastTempSchedPickOrder.indexOf(b.id) + + const aInLast = aIndex !== -1 + const bInLast = bIndex !== -1 + + if (!aInLast && bInLast) return -1 // a is new + if (aInLast && !bInLast) return 1 // b is new + if (!aInLast && !bInLast) { + return a.name.localeCompare(b.name) // both new + } + + if (aIndex !== bIndex) { + // Reverse order: higher index = more recently picked = comes earlier + return bIndex - aIndex + } + + return a.name.localeCompare(b.name) // tie-breaker +} diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index 809ba283fd..5f67cf079a 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -791,6 +791,7 @@ export interface Mutation { setScheduleOnCallNotificationRules: boolean setSystemLimits: boolean setTemporarySchedule: boolean + setTemporarySchedulePickOrder: boolean swoAction: boolean testContactMethod: boolean updateAlerts?: null | Alert[] @@ -1008,6 +1009,7 @@ export interface Schedule { description: string id: string isFavorite: boolean + lastTempSchedPickOrder: string[] name: string onCallNotificationRules: OnCallNotificationRule[] shifts: OnCallShift[] @@ -1135,6 +1137,11 @@ export interface SetScheduleShiftInput { userID: string } +export interface SetTempSchedPickOrderInput { + scheduleID: string + userIDs: string[] +} + export interface SetTemporaryScheduleInput { clearEnd?: null | ISOTimestamp clearStart?: null | ISOTimestamp From 985831995136a11b9b5530b599763a8a00097d1c Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 15:23:07 -0700 Subject: [PATCH 14/20] validate userids --- schedule/store.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/schedule/store.go b/schedule/store.go index 0183ba0d6b..c59b92a355 100644 --- a/schedule/store.go +++ b/schedule/store.go @@ -417,6 +417,11 @@ func (store *Store) SetTempSchedPickOrder(ctx context.Context, scheduleID string return false, err } + err = validate.ManyUUID("UserID", userIDs, 200) + if err != nil { + return false, err + } + _, err = store.setTempSchedPickOrder.ExecContext(ctx, scheduleID, userIDs) if err != nil { return false, err From 9a64f2744bad34bd758f8bc796fee6d16ee234b3 Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 15:49:56 -0700 Subject: [PATCH 15/20] upsert data to table row instead of rewrite --- schedule/store.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/schedule/store.go b/schedule/store.go index c59b92a355..430c773769 100644 --- a/schedule/store.go +++ b/schedule/store.go @@ -107,7 +107,21 @@ func NewStore(ctx context.Context, db *sql.DB, usr *user.Store) (*Store, error) DO UPDATE SET data = jsonb_set( COALESCE(schedule_data.data, '{}'), '{user_ids}', - to_jsonb($2::text[]), + to_jsonb( + ( + SELECT ARRAY( + SELECT unnest($2::text[]) + UNION ALL + SELECT u + FROM unnest( + COALESCE(( + SELECT jsonb_array_elements_text(schedule_data.data->'user_ids') + )::text[], '{}') + ) AS u + WHERE u <> ALL($2::text[]) + ) + ) + ), true ); `), From f901fd9b0cc1f908f4bac3bdfc6427c036c66249 Mon Sep 17 00:00:00 2001 From: Forfold Date: Thu, 24 Jul 2025 16:22:33 -0700 Subject: [PATCH 16/20] fix insert overwriting everything in jsonb --- schedule/store.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/schedule/store.go b/schedule/store.go index 430c773769..ea7738bd07 100644 --- a/schedule/store.go +++ b/schedule/store.go @@ -107,21 +107,21 @@ func NewStore(ctx context.Context, db *sql.DB, usr *user.Store) (*Store, error) DO UPDATE SET data = jsonb_set( COALESCE(schedule_data.data, '{}'), '{user_ids}', - to_jsonb( - ( - SELECT ARRAY( - SELECT unnest($2::text[]) + to_jsonb(( + SELECT ARRAY( + SELECT * FROM ( + SELECT unnest($2::text[]) AS user_id UNION ALL - SELECT u - FROM unnest( - COALESCE(( - SELECT jsonb_array_elements_text(schedule_data.data->'user_ids') - )::text[], '{}') - ) AS u - WHERE u <> ALL($2::text[]) - ) + SELECT user_id + FROM ( + SELECT jsonb_array_elements_text(sd.data->'user_ids') AS user_id + FROM schedule_data sd + WHERE sd.schedule_id = $1 + ) existing + WHERE user_id <> ALL($2::text[]) + ) merged ) - ), + )), true ); `), From 76da249fd5d1207ac149a9b084d037cb6266b390 Mon Sep 17 00:00:00 2001 From: Forfold Date: Mon, 28 Jul 2025 07:49:27 -0700 Subject: [PATCH 17/20] fix prop --- web/src/explore/explore.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/explore/explore.tsx b/web/src/explore/explore.tsx index 0ac56e9e85..477d3bbd50 100644 --- a/web/src/explore/explore.tsx +++ b/web/src/explore/explore.tsx @@ -35,7 +35,7 @@ const App = (): JSX.Element => { return ( { const resp = await fetch(location.protocol + '//' + path, { method: 'POST', From 851fe4455406932f9985f88e883aaa7fcef28e2e Mon Sep 17 00:00:00 2001 From: Forfold Date: Mon, 3 Nov 2025 09:28:46 -0800 Subject: [PATCH 18/20] fix merge --- schedule/store.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/schedule/store.go b/schedule/store.go index d99135bb74..48e21a3ef6 100644 --- a/schedule/store.go +++ b/schedule/store.go @@ -17,12 +17,61 @@ import ( type Store struct { db *sql.DB usr *user.Store + + findAssociatedUserIDs *sql.Stmt + findLastTempSchedPickOrder *sql.Stmt + setTempSchedPickOrder *sql.Stmt } func NewStore(ctx context.Context, db *sql.DB, usr *user.Store) (*Store, error) { + p := &util.Prepare{ + DB: db, + Ctx: ctx, + } + return &Store{ db: db, usr: usr, + + findAssociatedUserIDs: p.P(` + SELECT DISTINCT COALESCE(s.tgt_user_id, r.user_id) + FROM schedule_rules s + LEFT JOIN rotation_participants r ON r.rotation_id = s.tgt_rotation_id + WHERE s.schedule_id = $1 + `), + findLastTempSchedPickOrder: p.P(` + SELECT data -> 'user_ids' AS user_ids + FROM schedule_data + WHERE schedule_id = $1; + `), + setTempSchedPickOrder: p.P(` + INSERT INTO schedule_data (schedule_id, data) + VALUES ( + $1, + jsonb_build_object('user_ids', to_jsonb($2::text[])) + ) + ON CONFLICT (schedule_id) + DO UPDATE SET data = jsonb_set( + COALESCE(schedule_data.data, '{}'), + '{user_ids}', + to_jsonb(( + SELECT ARRAY( + SELECT * FROM ( + SELECT unnest($2::text[]) AS user_id + UNION ALL + SELECT user_id + FROM ( + SELECT jsonb_array_elements_text(sd.data->'user_ids') AS user_id + FROM schedule_data sd + WHERE sd.schedule_id = $1 + ) existing + WHERE user_id <> ALL($2::text[]) + ) merged + ) + )), + true + ); + `), }, nil } From b9794bcbd1587b36efb90cc7e196c3f9cd783d5b Mon Sep 17 00:00:00 2001 From: Forfold Date: Mon, 3 Nov 2025 09:36:31 -0800 Subject: [PATCH 19/20] gen --- graphql2/generated.go | 168 ++++++++++++++++-------------------------- 1 file changed, 65 insertions(+), 103 deletions(-) diff --git a/graphql2/generated.go b/graphql2/generated.go index 5afb7959fe..d7d1eeab69 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -1,4 +1,4 @@ -s// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. +// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. package graphql2 @@ -2960,6 +2960,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Mutation.SetTemporarySchedule(childComplexity, args["input"].(SetTemporaryScheduleInput)), true + case "Mutation.setTemporarySchedulePickOrder": + if e.complexity.Mutation.SetTemporarySchedulePickOrder == nil { + break + } + + args, err := ec.field_Mutation_setTemporarySchedulePickOrder_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SetTemporarySchedulePickOrder(childComplexity, args["input"].(SetTempSchedPickOrderInput)), true case "Mutation.swoAction": if e.complexity.Mutation.SwoAction == nil { break @@ -4077,6 +4088,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Schedule.AssignedTo(childComplexity), true + case "Schedule.associatedUsers": + if e.complexity.Schedule.AssociatedUsers == nil { + break + } + + return e.complexity.Schedule.AssociatedUsers(childComplexity), true case "Schedule.description": if e.complexity.Schedule.Description == nil { break @@ -4095,6 +4112,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Schedule.IsFavorite(childComplexity), true + case "Schedule.lastTempSchedPickOrder": + if e.complexity.Schedule.LastTempSchedPickOrder == nil { + break + } + + return e.complexity.Schedule.LastTempSchedPickOrder(childComplexity), true case "Schedule.name": if e.complexity.Schedule.Name == nil { break @@ -5570,30 +5593,13 @@ func (ec *executionContext) field_Mutation_setSystemLimits_args(ctx context.Cont func (ec *executionContext) field_Mutation_setTemporarySchedulePickOrder_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := ec.field_Mutation_setTemporarySchedulePickOrder_argsInput(ctx, rawArgs) + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNSetTempSchedPickOrderInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSetTempSchedPickOrderInput) if err != nil { return nil, err } args["input"] = arg0 return args, nil } -func (ec *executionContext) field_Mutation_setTemporarySchedulePickOrder_argsInput( - ctx context.Context, - rawArgs map[string]any, -) (SetTempSchedPickOrderInput, error) { - if _, ok := rawArgs["input"]; !ok { - var zeroVal SetTempSchedPickOrderInput - return zeroVal, nil - } - - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) - if tmp, ok := rawArgs["input"]; ok { - return ec.unmarshalNSetTempSchedPickOrderInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐSetTempSchedPickOrderInput(ctx, tmp) - } - - var zeroVal SetTempSchedPickOrderInput - return zeroVal, nil -} func (ec *executionContext) field_Mutation_setTemporarySchedule_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error @@ -13411,34 +13417,20 @@ func (ec *executionContext) fieldContext_Mutation_setTemporarySchedule(ctx conte } func (ec *executionContext) _Mutation_setTemporarySchedulePickOrder(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Mutation_setTemporarySchedulePickOrder(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().SetTemporarySchedulePickOrder(rctx, fc.Args["input"].(SetTempSchedPickOrderInput)) - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(bool) - fc.Result = res - return ec.marshalNBoolean2bool(ctx, field.Selections, res) + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_setTemporarySchedulePickOrder, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Mutation().SetTemporarySchedulePickOrder(ctx, fc.Args["input"].(SetTempSchedPickOrderInput)) + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) } func (ec *executionContext) fieldContext_Mutation_setTemporarySchedulePickOrder(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -20917,34 +20909,19 @@ func (ec *executionContext) fieldContext_Schedule_shifts(ctx context.Context, fi } func (ec *executionContext) _Schedule_associatedUsers(ctx context.Context, field graphql.CollectedField, obj *schedule.Schedule) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Schedule_associatedUsers(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Schedule().AssociatedUsers(rctx, obj) - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.([]user.User) - fc.Result = res - return ec.marshalNUser2ᚕgithubᚗcomᚋtargetᚋgoalertᚋuserᚐUserᚄ(ctx, field.Selections, res) + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Schedule_associatedUsers, + func(ctx context.Context) (any, error) { + return ec.resolvers.Schedule().AssociatedUsers(ctx, obj) + }, + nil, + ec.marshalNUser2ᚕgithubᚗcomᚋtargetᚋgoalertᚋuserᚐUserᚄ, + true, + true, + ) } func (ec *executionContext) fieldContext_Schedule_associatedUsers(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -20991,34 +20968,19 @@ func (ec *executionContext) fieldContext_Schedule_associatedUsers(_ context.Cont } func (ec *executionContext) _Schedule_lastTempSchedPickOrder(ctx context.Context, field graphql.CollectedField, obj *schedule.Schedule) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Schedule_lastTempSchedPickOrder(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Schedule().LastTempSchedPickOrder(rctx, obj) - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.([]string) - fc.Result = res - return ec.marshalNID2ᚕstringᚄ(ctx, field.Selections, res) + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Schedule_lastTempSchedPickOrder, + func(ctx context.Context) (any, error) { + return ec.resolvers.Schedule().LastTempSchedPickOrder(ctx, obj) + }, + nil, + ec.marshalNID2ᚕstringᚄ, + true, + true, + ) } func (ec *executionContext) fieldContext_Schedule_lastTempSchedPickOrder(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { From 2249fbaf5f2ed6b112b15d118f4a38a421ef58df Mon Sep 17 00:00:00 2001 From: Nathaniel Cook Date: Wed, 5 Nov 2025 12:14:24 -0800 Subject: [PATCH 20/20] Update web/src/app/schedules/temp-sched/TempSchedDialog.tsx Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- web/src/app/schedules/temp-sched/TempSchedDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx index 897af1cd1a..71632b3655 100644 --- a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx @@ -444,7 +444,7 @@ export default function TempSchedDialog({ ), }) - let order = pickOrder.slice() + const order = pickOrder.slice() const i = order.indexOf(shift.userID) if (i !== -1) { order.splice(i, 1)