Files
social-media/frontend/src/features/workspaces/components/ApprovalWorkflowEditor.vue
Jonathan Bourdon 1ca6ab7117
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 19s
feat: centralize frontend Vuetify styling
2026-05-08 13:45:42 -04:00

422 lines
13 KiB
Vue

<script setup>
import { computed } from 'vue';
import {
mdiArrowDown,
mdiArrowUp,
mdiDeleteOutline,
mdiPlus,
} from '@mdi/js';
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
members: {
type: Array,
default: () => [],
},
errors: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
labels: {
type: Object,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const roleOptions = [
'administrator',
'manager',
'workspace-member',
'client',
'provider',
];
const membershipOptions = ['Team', 'Client'];
const targetTypes = ['Role', 'Membership', 'Member'];
const roleItems = computed(() => roleOptions.map(role => ({
title: props.labels.roles[role],
value: role,
})));
const membershipItems = computed(() => membershipOptions.map(membership => ({
title: props.labels.memberships[membership],
value: membership,
})));
const targetTypeItems = computed(() => targetTypes.map(targetType => ({
title: props.labels.targetTypes[targetType],
value: targetType,
})));
const memberItems = computed(() => props.members.map(member => ({
title: `${member.displayName} - ${member.email}`,
value: member.id,
})));
function emitSteps(steps) {
emit('update:modelValue', steps.map((step, index) => ({
...step,
sortOrder: index,
})));
}
function createStep() {
emitSteps([
...props.modelValue,
{
name: props.labels.defaultStepName(props.modelValue.length + 1),
sortOrder: props.modelValue.length,
targetType: 'Role',
targetValue: 'manager',
requiredApproverCount: 1,
},
]);
}
function updateStep(index, updates) {
const steps = props.modelValue.map((step, stepIndex) => {
if (stepIndex !== index) {
return step;
}
const nextStep = {
...step,
...updates,
};
if (updates.targetType) {
nextStep.targetValue = defaultTargetValue(updates.targetType);
}
return nextStep;
});
emitSteps(steps);
}
function defaultTargetValue(targetType) {
if (targetType === 'Membership') {
return membershipOptions[0];
}
if (targetType === 'Member') {
return props.members[0]?.id ?? '';
}
return roleOptions[1];
}
function getSelectedMemberIds(step) {
return (step.targetValue ?? '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
}
function updateMemberTargets(index, selectedMemberIds) {
updateStep(index, { targetValue: selectedMemberIds.filter(Boolean).join(',') });
}
function moveStep(index, offset) {
const nextIndex = index + offset;
if (nextIndex < 0 || nextIndex >= props.modelValue.length) {
return;
}
const steps = [...props.modelValue];
const [step] = steps.splice(index, 1);
steps.splice(nextIndex, 0, step);
emitSteps(steps);
}
function removeStep(index) {
emitSteps(props.modelValue.filter((_, stepIndex) => stepIndex !== index));
}
</script>
<template>
<div class="approval-workflow-editor">
<div class="approval-editor-header">
<div>
<strong>{{ labels.title }}</strong>
<span>{{ labels.description }}</span>
</div>
<v-btn variant="text" :ripple="false"
type="button"
class="secondary-button"
:disabled="disabled"
@click="createStep"
>
<v-icon :icon="mdiPlus" />
<span>{{ labels.addStep }}</span>
</v-btn>
</div>
<div
v-if="!modelValue.length"
class="approval-empty"
>
{{ labels.empty }}
</div>
<div
v-else
class="approval-step-list"
>
<section
v-for="(step, index) in modelValue"
:key="step.id ?? `${index}-${step.sortOrder}`"
class="approval-step-card"
>
<div class="approval-step-heading">
<div>
<small>{{ labels.stepNumber(index + 1) }}</small>
<strong>{{ step.name || labels.unnamedStep }}</strong>
</div>
<div class="approval-step-actions">
<v-btn variant="text" :ripple="false"
type="button"
:aria-label="labels.moveUp"
:disabled="disabled || index === 0"
@click="moveStep(index, -1)"
>
<v-icon :icon="mdiArrowUp" />
</v-btn>
<v-btn variant="text" :ripple="false"
type="button"
:aria-label="labels.moveDown"
:disabled="disabled || index === modelValue.length - 1"
@click="moveStep(index, 1)"
>
<v-icon :icon="mdiArrowDown" />
</v-btn>
<v-btn variant="text" :ripple="false"
type="button"
:aria-label="labels.removeStep"
:disabled="disabled"
@click="removeStep(index)"
>
<v-icon :icon="mdiDeleteOutline" />
</v-btn>
</div>
</div>
<div class="approval-step-fields">
<div class="field">
<v-text-field
:model-value="step.name"
:label="labels.fields.name"
:disabled="disabled"
variant="outlined"
hide-details
@update:model-value="updateStep(index, { name: $event })"
/>
<small
v-if="errors[index]?.name"
class="field-error"
>
{{ errors[index].name }}
</small>
</div>
<v-select
:model-value="step.targetType"
:items="targetTypeItems"
:label="labels.fields.targetType"
:disabled="disabled"
variant="outlined"
hide-details
@update:model-value="updateStep(index, { targetType: $event })"
/>
<div class="field">
<v-select
v-if="step.targetType === 'Role'"
:model-value="step.targetValue"
:items="roleItems"
:label="labels.fields.targetValue"
:disabled="disabled"
variant="outlined"
hide-details
@update:model-value="updateStep(index, { targetValue: $event })"
/>
<v-select
v-else-if="step.targetType === 'Membership'"
:model-value="step.targetValue"
:items="membershipItems"
:label="labels.fields.targetValue"
:disabled="disabled"
variant="outlined"
hide-details
@update:model-value="updateStep(index, { targetValue: $event })"
/>
<v-select
v-else
:model-value="getSelectedMemberIds(step)"
:items="memberItems"
:label="labels.fields.targetValue"
:disabled="disabled"
multiple
chips
closable-chips
variant="outlined"
hide-details
@update:model-value="updateMemberTargets(index, $event)"
/>
<small
v-if="step.targetType === 'Member'"
class="field-help"
>
{{ labels.selectMembers }}
</small>
<small
v-if="errors[index]?.targetValue"
class="field-error"
>
{{ errors[index].targetValue }}
</small>
</div>
<div class="field">
<v-text-field
:model-value="step.requiredApproverCount"
:label="labels.fields.requiredApproverCount"
type="number"
min="1"
step="1"
:disabled="disabled"
variant="outlined"
hide-details
@update:model-value="updateStep(index, { requiredApproverCount: Number($event) })"
/>
<small
v-if="errors[index]?.requiredApproverCount"
class="field-error"
>
{{ errors[index].requiredApproverCount }}
</small>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
@reference "@/assets/main.css";
.approval-workflow-editor {
@apply flex flex-col gap-3;
}
.approval-editor-header {
@apply flex flex-col gap-3 rounded-[1rem] border px-4 py-4 sm:flex-row sm:items-center sm:justify-between;
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.approval-editor-header div,
.approval-step-heading div:first-child {
@apply flex min-w-0 flex-col gap-1;
}
.approval-editor-header strong,
.approval-step-heading strong {
color: var(--app-color-on-surface);
}
.approval-editor-header span,
.approval-empty,
.approval-step-heading small {
@apply text-sm leading-6;
color: var(--app-text-muted);
}
.approval-step-list {
@apply flex flex-col gap-3;
}
.approval-empty,
.approval-step-card {
@apply rounded-[1rem] border px-4 py-4;
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.approval-step-card {
@apply flex flex-col gap-4;
}
.approval-step-heading {
@apply flex items-start justify-between gap-3;
}
.approval-step-actions {
@apply flex flex-shrink-0 gap-2;
}
.approval-step-actions button {
@apply inline-flex h-9 w-9 items-center justify-center rounded-full;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.approval-step-actions button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.approval-step-fields {
@apply grid gap-3 md:grid-cols-2;
}
.secondary-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm font-semibold;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.secondary-button:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.field {
@apply flex flex-col gap-2;
}
.field span {
@apply text-sm font-semibold;
color: var(--app-color-on-surface);
}
.field input,
.field select {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: var(--app-color-surface);
border-color: var(--app-control-active);
color: var(--app-color-on-surface);
outline: none;
}
.field-error {
@apply text-sm leading-6;
color: var(--app-danger-muted);
}
.field-help {
@apply text-sm leading-6;
color: var(--app-text-muted);
}
</style>