Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 87 additions & 6 deletions client/app/components/AssignmentFinishedModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,26 @@
{{ props.rfcToBe.name }}
</span>
</h1>
<BaseButton btnType="cancel" class="m-2 flex items-center" @click="closeOverlayModal">
<Icon name="uil:times" class="h-5 w-5" aria-hidden="true" />
</BaseButton>
<div class="m-2 flex items-center gap-2">
<BaseButton
btnType="default"
class="flex items-center disabled:opacity-60 disabled:cursor-not-allowed"
:disabled="isBlocked"
:title="
isBlocked
? 'This document is blocked — resolve the block before adding an assignment.'
: undefined
"
@click="openAddAssignmentModal">
<span>Add assignment</span>
<span v-if="isLoadingAdd" class="w-3">
<Icon name="ei:spinner-3" size="1rem" class="animate-spin" />
</span>
</BaseButton>
<BaseButton btnType="cancel" class="flex items-center" @click="closeOverlayModal">
<Icon name="uil:times" class="h-5 w-5" aria-hidden="true" />
</BaseButton>
</div>
</div>
<div class="flex-1 overflow-y-scroll px-4 pt-4 pb-7">
<ul class="flex flex-col gap-4">
Expand All @@ -36,11 +53,13 @@
</template>

<script setup lang="ts">
import { BaseButton } from '#components'
import type { Assignment, RpcPerson } from '~/purple_client'
import { BaseButton, AssignmentModal } from '#components'
import type { Assignment, RpcPerson, RpcRole } from '~/purple_client'
import { StateEnum } from '~/purple_client'
import { overlayModalKey } from '~/providers/providerKeys'
import { groupBy } from 'es-toolkit/array'
import { assignmentRoleOrder } from '~/utils/sort'
import { calculatePeopleWorkload, type AssignmentMessageProps } from '~/utils/queue'

type Props = {
rfcToBe: CookedDraft
Expand All @@ -50,6 +69,14 @@ type Props = {
}
const props = defineProps<Props>()

const api = useApi()

// A blocked doc's work assignments are closed and held by an active (in_progress)
// 'blocked' assignment, so adding a new one would create an inconsistent state.
const isBlocked = computed(() =>
props.assignments.some((a) => a.role === 'blocked' && a.state === StateEnum.InProgress)
)

const assignmentsByRolesObj = groupBy(props.assignments, (assignment) => assignment.role)

const assignmentsByRoles = ref(
Expand All @@ -72,5 +99,59 @@ if (!overlayModalKeyInjection) {
throw Error('Expected injection of overlayModalKey')
}

const { closeOverlayModal } = overlayModalKeyInjection
const { openOverlayModal, closeOverlayModal } = overlayModalKeyInjection

// "Add assignment" — folds the standalone add-assignment flow into this modal.
// Loads the data the add picker needs, then opens AssignmentModal in 'add' mode
// (which replaces this modal in the single overlay slot).
const isLoadingAdd = ref(false)

const roleOrderIndex = (slug: string) =>
assignmentRoleOrder.indexOf(slug as (typeof assignmentRoleOrder)[number])

const openAddAssignmentModal = async () => {
const rfcToBeId = props.rfcToBe.id
if (rfcToBeId === undefined || isBlocked.value) return

isLoadingAdd.value = true
try {
const [roles, clusters, queueItems] = await Promise.all([
api.rpcRolesList(),
api.clustersList(),
api.queueList()
])

// Only assignment-pipeline roles are selectable (excludes `manager` and the
// synthetic `blocked`), ordered by the canonical pipeline order.
const roleOptions = roles
.filter((r) => (assignmentRoleOrder as readonly string[]).includes(r.slug))
.sort((a, b) => roleOrderIndex(a.slug) - roleOrderIndex(b.slug))

// Default to the draft's next pending activity (earliest in pipeline order),
// falling back to the first selectable role.
const pending = [...(props.rfcToBe.pendingActivities ?? [])].sort(
(a, b) => roleOrderIndex(a.slug) - roleOrderIndex(b.slug)
)
const defaultRole = pending[0]?.slug ?? roleOptions[0]?.slug ?? ''

const peopleWorkload = calculatePeopleWorkload(clusters, queueItems)

openOverlayModal({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await this?

It seems we have inconsistent patterns across the codebase for this so I'm not sure what the correct pattern is.

If it is awaited then the noop .catch(() => {}) is probably redundant, considering the following catch.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's in an async, awaiting would make sense to me. . .

component: AssignmentModal,
componentProps: {
message: { type: 'add', rfcToBeId } satisfies AssignmentMessageProps,
people: props.people,
peopleWorkload,
clusters,
roles: roleOptions,
defaultRole,
onSuccess: props.onSuccess
}
}).catch(() => {})
} catch (e) {
console.error(e)
} finally {
isLoadingAdd.value = false
}
}
</script>
56 changes: 48 additions & 8 deletions client/app/components/AssignmentModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
<BaseBadge :label="props.message.role" size="xl"></BaseBadge>
assignment
</span>
<span v-else-if="props.message.type === 'add'" class="flex items-center gap-2">
Add
<select
v-model="selectedRole"
class="text-base font-normal border border-gray-300 dark:border-gray-600 rounded px-2 py-1 bg-white dark:bg-neutral-800 min-w-[14rem]">
<option v-for="role in roleOptions" :key="role.slug" :value="role.slug">
{{ role.name }}
</option>
</select>
assignment
</span>
</h1>
<BaseButton btnType="cancel" class="m-2 flex items-center" @click="closeOverlayModal">
<Icon name="uil:times" class="h-5 w-5" aria-hidden="true" />
Expand Down Expand Up @@ -94,7 +105,7 @@
<script setup lang="ts">
import { watch } from 'vue'
import { BaseButton } from '#components'
import type { Assignment, RpcPerson } from '~/purple_client'
import type { Assignment, RpcPerson, RpcRole } from '~/purple_client'
import type { AssignmentMessageProps } from '~/utils/queue'
import type { ResolvedQueueItem } from './AssignmentsTypes'
import { overlayModalKey } from '~/providers/providerKeys'
Expand All @@ -110,9 +121,28 @@ type Props = {
peopleWorkload: Record<number, RpcPersonWorkload>
clusters: ResolvedCluster[]
onSuccess: () => void
// Only used by the 'add' message type: the roles the assigner can choose from
// (caller should exclude the synthetic 'blocked' role) and the role to
// pre-select (typically the draft's next pending activity).
roles?: RpcRole[]
defaultRole?: Assignment['role']
}
const props = defineProps<Props>()

// Role options for the 'add' picker; defensively drop the synthetic 'blocked'
// role even if the caller forgot to.
const roleOptions = computed(() => (props.roles ?? []).filter((role) => role.slug !== 'blocked'))

const selectedRole = ref<Assignment['role']>(
props.message.type === 'add' ? (props.defaultRole ?? roleOptions.value[0]?.slug ?? '') : ''
)

// The role an 'assign' action should use: chosen in the modal for 'add', or
// fixed by the trigger for 'assign'/'change'.
const effectiveRole = computed<Assignment['role']>(() =>
props.message.type === 'add' ? selectedRole.value : props.message.role
)

const generateId = (personId: number | undefined, personIndex: number): string =>
`person-${personId ?? personIndex}`

Expand All @@ -125,7 +155,9 @@ if (!overlayModalKeyInjection) {
type SelectedPeople = Record<number, boolean>

const getInitialState = (message: AssignmentMessageProps): SelectedPeople => {
if (message.type === 'assign') {
// 'assign' and 'add' start with nobody selected; only 'change' pre-selects the
// people currently assigned to the role.
if (message.type !== 'change') {
return {}
}
return message.assignments.reduce((acc, assignment) => {
Expand Down Expand Up @@ -229,12 +261,12 @@ watch(
}
})
)
} else if (props.message.type === 'assign') {
const message = props.message
} else if (props.message.type === 'assign' || props.message.type === 'add') {
const rfcToBeId = props.message.rfcToBeId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
const rfcToBeId = props.message.rfcToBeId
const { rfcToBeId } = props.message

// withdrawals
// there are no withdrawals when initially assigning
// there are no withdrawals when initially assigning or adding

// new assignments
// new assignments — role is fixed for 'assign', chosen for 'add'
newActions.push(
...Object.entries(isPersonSelected.value)
.filter(([personIdString, isSelected]) => {
Expand All @@ -245,18 +277,26 @@ watch(
return {
type: 'assign',
personId,
rfcToBeId: message.rfcToBeId,
role: message.role
rfcToBeId,
role: effectiveRole.value
}
})
)
}

actions.value = newActions
// Changing the role in 'add' mode must rebuild the pending actions with the
// new role, so watch selectedRole alongside the person selection.
},
{ deep: true }
)

watch(selectedRole, () => {
actions.value = actions.value.map((action) =>
action.type === 'assign' ? { ...action, role: selectedRole.value } : action
)
})

// filter the list of editors to those who are active or currently assigned
const visiblePeople = computed(() => {
return props.people.filter((person) => {
Expand Down
7 changes: 4 additions & 3 deletions client/app/pages/docs/[id]/assignments.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ import { useAsyncData } from '#app'
import { snackbarForErrors } from '~/utils/snackbar'
import { type DocTabId } from '~/utils/doc'
import { teamMemberLink } from '~/utils/url'
import type { Assignment } from '~/purple_client'
import type { Assignment, RpcPerson } from '~/purple_client'
import { overlayModalKey } from '~/providers/providerKeys'
import { ManualHoldModal } from '#components'
import { sortAssignmentsByRole } from '~/utils/sort'
Expand Down Expand Up @@ -255,9 +255,10 @@ watch(
{ deep: true }
)

const { data: people } = await useAsyncData(() => api.rpcPersonList(), {
const { data: people } = await useAsyncData('rpc-people', () => api.rpcPersonList(), {
server: false,
lazy: true
lazy: true,
default: () => [] as RpcPerson[]
})

useHeadSafe({ title: draftName.value })
Expand Down
8 changes: 8 additions & 0 deletions client/app/utils/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ export type AssignmentMessageProps =
role: Assignment['role']
rfcToBeId: number
}
| {
// Add an assignment of any role to a draft. Unlike 'assign' (role fixed by
// the pending-activity button that opened it), the assigner picks the role
// in the modal — allowing out-of-pipeline roles or re-adding a role whose
// earlier assignment was closed too early.
type: 'add'
rfcToBeId: number
}

export type RpcPersonWorkload = {
personId: number
Expand Down