Skip to content

Commit 7cc74de

Browse files
committed
- Added the StepBulkEntry obsidian block and chopped webforms.
1 parent 5af1973 commit 7cc74de

23 files changed

Lines changed: 2101 additions & 1406 deletions

Rock.Blocks/Engagement/StepBulkEntry.cs

Lines changed: 647 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<!-- Copyright by the Spark Development Network; Licensed under the Rock Community License -->
2+
<template>
3+
<PillList :modelValue="modelValue"
4+
label="People"
5+
canAdd
6+
canRemove
7+
canExpand
8+
@add="onAdd"
9+
@remove="onRemove">
10+
<template #item="{ item, disabled, canRemove, remove }">
11+
<Pill :disabled="disabled"
12+
:canRemove="canRemove"
13+
:tokenType="getPersonTokenType(item)"
14+
:tooltip="getPersonTooltip(item)"
15+
@remove="remove()">
16+
<img :src="item.photoUrl ?? undefined"
17+
style="border-radius: 24px; width: 24px; height: 24px" />
18+
<span>{{ item.name }}</span>
19+
</Pill>
20+
</template>
21+
</PillList>
22+
23+
<NotificationBox v-if="invalidResults.length > 0" alertType="warning">
24+
<strong>{{ invalidResults.length }} {{ invalidResults.length !== 1 ? "people" : "person" }} cannot have a step created:</strong>
25+
26+
<div v-for="result in invalidResults"
27+
:key="result.personAliasGuid"
28+
class="mt-2">
29+
<span class="text-semibold">{{ result.personName }}</span>
30+
<ul v-if="result.errors && result.errors.length > 0" class="mb-0 mt-1">
31+
<li v-for="(error, idx) in result.errors" :key="idx">{{ error }}</li>
32+
</ul>
33+
</div>
34+
</NotificationBox>
35+
36+
<PersonPicker v-if="isPersonPickerShown"
37+
v-model:isSearchModeOpen="isPersonPickerShown"
38+
@update:modelValue="onPersonSelected" />
39+
</template>
40+
41+
<script setup lang="ts">
42+
import { computed, PropType, ref } from "vue";
43+
import NotificationBox from "@Obsidian/Controls/notificationBox.obs";
44+
import Pill from "@Obsidian/Controls/pill.obs";
45+
import PillList from "@Obsidian/Controls/pillList.obs";
46+
import PersonPicker from "@Obsidian/Controls/personPicker.obs";
47+
import { useInvokeBlockAction } from "@Obsidian/Utility/block";
48+
import { Guid } from "@Obsidian/Types";
49+
import { ListItemBag } from "@Obsidian/ViewModels/Utility/listItemBag";
50+
import { StepBulkEntryPersonItemBag } from "@Obsidian/ViewModels/Blocks/Engagement/StepBulkEntry/stepBulkEntryPersonItemBag";
51+
import { StepBulkEntryValidatePersonResultBag } from "@Obsidian/ViewModels/Blocks/Engagement/StepBulkEntry/stepBulkEntryValidatePersonResultBag";
52+
53+
const props = defineProps({
54+
modelValue: {
55+
type: Array as PropType<StepBulkEntryPersonItemBag[]>,
56+
required: true
57+
},
58+
59+
/** Per-person validation results keyed by person alias Guid. Provided by parent. */
60+
validationResults: {
61+
type: Object as PropType<Record<string, StepBulkEntryValidatePersonResultBag>>,
62+
default: () => ({})
63+
}
64+
});
65+
66+
const emit = defineEmits<{
67+
(e: "update:modelValue", value: StepBulkEntryPersonItemBag[]): void;
68+
(e: "personAdded", personAliasGuid: Guid): void;
69+
(e: "error", message: string): void;
70+
}>();
71+
72+
const invokeBlockAction = useInvokeBlockAction();
73+
74+
// #region Values
75+
76+
const isPersonPickerShown = ref(false);
77+
78+
// #endregion
79+
80+
// #region Computed Values
81+
82+
/** The validation results for people currently in the list who failed validation. */
83+
const invalidResults = computed<StepBulkEntryValidatePersonResultBag[]>(() => {
84+
const currentGuids = new Set(props.modelValue.map(p => p.personAliasGuid));
85+
86+
return Object.values(props.validationResults)
87+
.filter(r => !r.isValid && currentGuids.has(r.personAliasGuid));
88+
});
89+
90+
// #endregion
91+
92+
// #region Functions
93+
94+
/** Returns the pill token type based on validation state. */
95+
function getPersonTokenType(item: StepBulkEntryPersonItemBag): "default" | "danger" {
96+
const result = props.validationResults[item.personAliasGuid];
97+
98+
if (!result || result.isValid) {
99+
return "default";
100+
}
101+
102+
return "danger";
103+
}
104+
105+
/** Returns the pill tooltip based on validation errors. */
106+
function getPersonTooltip(item: StepBulkEntryPersonItemBag): string {
107+
const result = props.validationResults[item.personAliasGuid];
108+
109+
if (!result || result.isValid) {
110+
return "";
111+
}
112+
113+
return result.errors?.join(" ") ?? "";
114+
}
115+
116+
// #endregion
117+
118+
// #region Event Handlers
119+
120+
/** Opens the person picker when the add button is clicked. */
121+
function onAdd(): void {
122+
isPersonPickerShown.value = true;
123+
}
124+
125+
/** Handles a person being selected from the picker. */
126+
async function onPersonSelected(value: ListItemBag | undefined): Promise<void> {
127+
if (!value?.value) {
128+
return;
129+
}
130+
131+
// Check for duplicates.
132+
if (props.modelValue.some(p => p.personAliasGuid === value.value)) {
133+
isPersonPickerShown.value = false;
134+
return;
135+
}
136+
137+
// Resolve person details including photo URL.
138+
const result = await invokeBlockAction<StepBulkEntryPersonItemBag>("GetPersonItem", {
139+
personAliasGuid: value.value
140+
});
141+
142+
if (result?.isSuccess && result.data) {
143+
emit("update:modelValue", [...props.modelValue, result.data]);
144+
emit("personAdded", result.data.personAliasGuid);
145+
}
146+
else {
147+
emit("error", result?.errorMessage ?? "Failed to load person details.");
148+
}
149+
150+
isPersonPickerShown.value = false;
151+
}
152+
153+
/** Removes a person from the selection. */
154+
function onRemove(item: StepBulkEntryPersonItemBag, index: number): void {
155+
const updated = [...props.modelValue.slice(0, index), ...props.modelValue.slice(index + 1)];
156+
emit("update:modelValue", updated);
157+
}
158+
159+
// #endregion
160+
</script>
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<!-- Copyright by the Spark Development Network; Licensed under the Rock Community License -->
2+
<template>
3+
<div v-if="isProgramPickerVisible" class="row">
4+
<div class="col-md-6">
5+
<StepProgramPicker v-model="internalProgram"
6+
label="Step Program" />
7+
</div>
8+
</div>
9+
10+
<div class="row">
11+
<div class="col-md-6">
12+
<StepTypePicker v-model="internalType"
13+
label="Step Type"
14+
rules="required"
15+
:disabled="isTypePickerDisabled"
16+
:stepProgramGuid="stepProgramGuid ?? null" />
17+
</div>
18+
<div :class="stepTypeConfiguration?.hasEndDate ? 'col-md-3 col-sm-6' : 'col-md-6'">
19+
<DatePicker v-model="internalStartDate"
20+
:label="stepTypeConfiguration?.startDateLabel ?? 'Date'"
21+
:rules="stepTypeConfiguration?.isDateRequired ? 'required' : ''" />
22+
</div>
23+
<div v-if="stepTypeConfiguration?.hasEndDate" class="col-md-3 col-sm-6">
24+
<DatePicker v-model="internalEndDate"
25+
label="End Date" />
26+
</div>
27+
</div>
28+
29+
<div class="row">
30+
<div class="col-md-6">
31+
<StepStatusPicker v-model="internalStatus"
32+
label="Status"
33+
rules="required"
34+
:showBlankItem="true"
35+
:stepProgramGuid="stepProgramGuid ?? null" />
36+
</div>
37+
<div class="col-md-6">
38+
<CampusPicker v-model="internalCampus"
39+
label="Campus"
40+
forceVisible
41+
showBlankItem />
42+
</div>
43+
</div>
44+
45+
<AttributeValuesContainer v-if="stepTypeConfiguration?.stepAttributes && Object.keys(stepTypeConfiguration.stepAttributes).length > 0"
46+
v-model="internalAttributeValues"
47+
:attributes="stepTypeConfiguration.stepAttributes"
48+
isEditMode
49+
:numberOfColumns="2" />
50+
</template>
51+
52+
<script setup lang="ts">
53+
import { computed, PropType, ref, watch } from "vue";
54+
import AttributeValuesContainer from "@Obsidian/Controls/attributeValuesContainer.obs";
55+
import CampusPicker from "@Obsidian/Controls/campusPicker.obs";
56+
import DatePicker from "@Obsidian/Controls/datePicker.obs";
57+
import StepProgramPicker from "@Obsidian/Controls/stepProgramPicker.obs";
58+
import StepStatusPicker from "@Obsidian/Controls/stepStatusPicker.obs";
59+
import StepTypePicker from "@Obsidian/Controls/stepTypePicker.obs";
60+
import { Guid } from "@Obsidian/Types";
61+
import { ListItemBag } from "@Obsidian/ViewModels/Utility/listItemBag";
62+
import { StepBulkEntryStepTypeConfigurationBag } from "@Obsidian/ViewModels/Blocks/Engagement/StepBulkEntry/stepBulkEntryStepTypeConfigurationBag";
63+
64+
const props = defineProps({
65+
/** The current step type configuration, null until a step type is selected. */
66+
stepTypeConfiguration: {
67+
type: Object as PropType<StepBulkEntryStepTypeConfigurationBag | null>,
68+
default: null
69+
},
70+
71+
/** Whether the program picker should be shown. */
72+
isProgramPickerVisible: {
73+
type: Boolean as PropType<boolean>,
74+
default: true
75+
},
76+
77+
/** Whether the step type picker should be disabled (pre-selected via settings or URL). */
78+
isTypePickerDisabled: {
79+
type: Boolean as PropType<boolean>,
80+
default: false
81+
},
82+
83+
/** The initial step program from block settings or page parameters. */
84+
initialProgram: {
85+
type: Object as PropType<ListItemBag | null>,
86+
default: null
87+
},
88+
89+
/** The initial step type from block settings or page parameters. */
90+
initialType: {
91+
type: Object as PropType<ListItemBag | null>,
92+
default: null
93+
},
94+
95+
/** The start date value. */
96+
startDate: {
97+
type: String as PropType<string>,
98+
default: ""
99+
},
100+
101+
/** The end date value. */
102+
endDate: {
103+
type: String as PropType<string>,
104+
default: ""
105+
},
106+
107+
/** The campus value. */
108+
campus: {
109+
type: Object as PropType<ListItemBag | null>,
110+
default: null
111+
},
112+
113+
/** The step status value. */
114+
stepStatus: {
115+
type: Object as PropType<ListItemBag | null>,
116+
default: null
117+
},
118+
119+
/** The attribute values. */
120+
attributeValues: {
121+
type: Object as PropType<Record<string, string>>,
122+
default: () => ({})
123+
}
124+
});
125+
126+
const emit = defineEmits<{
127+
(e: "update:startDate", value: string): void;
128+
(e: "update:endDate", value: string): void;
129+
(e: "update:campus", value: ListItemBag | null): void;
130+
(e: "update:stepStatus", value: ListItemBag | null): void;
131+
(e: "update:attributeValues", value: Record<string, string>): void;
132+
(e: "stepTypeChanged", value: Guid | null): void;
133+
(e: "programChanged", value: Guid | null): void;
134+
}>();
135+
136+
// #region Values
137+
138+
// Program and type use internal refs because they have one-time initialization
139+
// and emit custom events (not v-model pass-through).
140+
const internalProgram = ref<ListItemBag | null>(props.initialProgram ?? null);
141+
const internalType = ref<ListItemBag | null>(props.initialType ?? null);
142+
143+
// v-model pass-through fields use computed get/set instead of
144+
// bilateral watchers (ref → emit + prop → ref).
145+
const internalStartDate = computed({
146+
get: () => props.startDate,
147+
set: (val) => emit("update:startDate", val)
148+
});
149+
150+
const internalEndDate = computed({
151+
get: () => props.endDate,
152+
set: (val) => emit("update:endDate", val)
153+
});
154+
155+
const internalCampus = computed({
156+
get: () => props.campus,
157+
set: (val) => emit("update:campus", val)
158+
});
159+
160+
const internalStatus = computed({
161+
get: () => props.stepStatus,
162+
set: (val) => emit("update:stepStatus", val)
163+
});
164+
165+
const internalAttributeValues = computed({
166+
get: () => props.attributeValues,
167+
set: (val) => emit("update:attributeValues", val)
168+
});
169+
170+
// #endregion
171+
172+
// #region Computed Values
173+
174+
/** The current step program Guid, derived from the configuration or the picker. */
175+
const stepProgramGuid = computed<Guid | null>(() => {
176+
return props.stepTypeConfiguration?.stepProgramGuid
177+
?? internalProgram.value?.value
178+
?? props.initialProgram?.value
179+
?? null;
180+
});
181+
182+
// #endregion
183+
184+
// #region Watchers
185+
186+
// Notify parent when the step type or program picker changes.
187+
watch(() => internalType.value?.value ?? null, (newGuid) => {
188+
emit("stepTypeChanged", newGuid);
189+
});
190+
191+
watch(() => internalProgram.value?.value ?? null, (newGuid) => {
192+
// Clear the step type since it may belong to a different program.
193+
internalType.value = null;
194+
emit("programChanged", newGuid);
195+
});
196+
197+
// #endregion
198+
</script>

0 commit comments

Comments
 (0)