Browse Source

feat: Add AdmitPendingMembersDialog for bulk member admission

- Add new AdmitPendingMembersDialog component with checkbox selection
- Support two action modes: "Admit + Add Contacts" and "Admit Only"
- Integrate dialog into MembersList with proper sequencing
- Show admit dialog before visibility dialog when pending members exist
- Fix auto-refresh pause/resume logic for both dialogs
- Ensure consistent dialog behavior between initial load and manual refresh
- Add proper async/await handling for data refresh operations
- Optimize dialog state management and remove redundant code
- Maintain proper flag timing to prevent race conditions

The admit dialog now shows automatically when there are pending members,
allowing organizers to efficiently admit multiple members at once while
optionally adding them as contacts and setting visibility preferences.
pull/211/head
Jose Olarte III 2 weeks ago
parent
commit
6fbc9c2a5b
  1. 458
      src/components/AdmitPendingMembersDialog.vue
  2. 175
      src/components/MembersList.vue

458
src/components/AdmitPendingMembersDialog.vue

@ -0,0 +1,458 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<div class="text-slate-900 text-center">
<h3 class="text-lg font-semibold leading-[1.25] mb-2">
Admit Pending Members
</h3>
<p class="text-sm mb-4">
The following members are waiting to be admitted to the meeting. You
can choose to admit them and optionally add them as contacts with
visibility settings.
</p>
<!-- Custom table area - you can customize this -->
<div v-if="shouldInitializeSelection" class="mb-4">
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
<thead v-if="pendingMembersData && pendingMembersData.length > 0">
<tr class="bg-slate-100 font-medium">
<th class="border border-slate-300 px-3 py-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
/>
Select All
</label>
</th>
</tr>
</thead>
<tbody>
<!-- Dynamic data from MembersList -->
<tr v-if="!pendingMembersData || pendingMembersData.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
>
No pending members to admit
</td>
</tr>
<tr
v-for="member in pendingMembersData || []"
:key="member.member.memberId"
>
<td class="border border-slate-300 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isMemberSelected(member.did)"
@change="toggleMemberSelection(member.did)"
/>
{{ member.name || SOMEONE_UNNAMED }}
</label>
<!-- Contact indicator - only show if they are already a contact -->
<font-awesome
v-if="member.isContact"
icon="user-circle"
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600"
@click="showContactInfo"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-2">
<button
v-if="pendingMembersData && pendingMembersData.length > 0"
:disabled="!hasSelectedMembers"
:class="[
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
hasSelectedMembers
? 'bg-green-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="admitAndSetVisibility"
>
Admit Pending + Add Contacts
</button>
<button
v-if="pendingMembersData && pendingMembersData.length > 0"
:disabled="!hasSelectedMembers"
:class="[
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
hasSelectedMembers
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="admitOnly"
>
Admit Pending Only
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="cancel"
>
Maybe Later
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { setVisibilityUtil, getHeaders } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
interface PendingMemberData {
did: string;
name: string;
isContact: boolean;
member: {
memberId: string;
};
}
@Component({
mixins: [PlatformServiceMixin],
})
export default class AdmitPendingMembersDialog extends Vue {
@Prop({ default: false }) visible!: boolean;
@Prop({ default: () => [] }) pendingMembersData!: PendingMemberData[];
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
// Vue notification system
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
// Notification system
notify!: ReturnType<typeof createNotifyHelpers>;
// Component state
selectedMembers: string[] = [];
selectionInitialized = false;
// Constants
// In Vue templates, imported constants need to be explicitly made available to the template
readonly SOMEONE_UNNAMED = SOMEONE_UNNAMED;
get hasSelectedMembers() {
return this.selectedMembers.length > 0;
}
get isAllSelected() {
if (!this.pendingMembersData || this.pendingMembersData.length === 0)
return false;
return this.pendingMembersData.every((member) =>
this.selectedMembers.includes(member.did),
);
}
get isIndeterminate() {
if (!this.pendingMembersData || this.pendingMembersData.length === 0)
return false;
const selectedCount = this.pendingMembersData.filter((member) =>
this.selectedMembers.includes(member.did),
).length;
return selectedCount > 0 && selectedCount < this.pendingMembersData.length;
}
get shouldInitializeSelection() {
// This method will initialize selection when the dialog opens
if (!this.selectionInitialized) {
this.initializeSelection();
this.selectionInitialized = true;
}
return true;
}
created() {
this.notify = createNotifyHelpers(this.$notify);
}
initializeSelection() {
// Reset selection when dialog opens
this.selectedMembers = [];
// Select all by default
this.selectedMembers = this.pendingMembersData.map((member) => member.did);
}
resetSelection() {
this.selectedMembers = [];
this.selectionInitialized = false;
}
toggleSelectAll() {
if (!this.pendingMembersData || this.pendingMembersData.length === 0)
return;
if (this.isAllSelected) {
// Deselect all
this.selectedMembers = [];
} else {
// Select all
this.selectedMembers = this.pendingMembersData.map(
(member) => member.did,
);
}
}
toggleMemberSelection(memberDid: string) {
const index = this.selectedMembers.indexOf(memberDid);
if (index > -1) {
this.selectedMembers.splice(index, 1);
} else {
this.selectedMembers.push(memberDid);
}
}
isMemberSelected(memberDid: string) {
return this.selectedMembers.includes(memberDid);
}
async admitAndSetVisibility() {
try {
const selectedMembers = this.pendingMembersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
let admittedCount = 0;
let contactAddedCount = 0;
let visibilitySetCount = 0;
for (const member of selectedMembers) {
try {
// First, admit the member
await this.admitMember(member);
admittedCount++;
// If they're not a contact yet, add them as a contact
if (!member.isContact) {
await this.addAsContact(member);
contactAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
visibilitySetCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `Admitted ${admittedCount} member${admittedCount === 1 ? "" : "s"}, added ${contactAddedCount} as contact${contactAddedCount === 1 ? "" : "s"}, and set visibility for ${visibilitySetCount} member${visibilitySetCount === 1 ? "" : "s"}.`,
},
5000,
);
// Emit success event
this.$emit("success", {
admittedCount,
contactAddedCount,
visibilitySetCount,
});
this.close();
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to admit some members. Please try again.",
},
5000,
);
}
}
async admitOnly() {
try {
const selectedMembers = this.pendingMembersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
let admittedCount = 0;
for (const member of selectedMembers) {
try {
// Just admit the member
await this.admitMember(member);
admittedCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error admitting member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `Admitted ${admittedCount} member${admittedCount === 1 ? "" : "s"} to the meeting.`,
},
5000,
);
// Emit success event
this.$emit("success", {
admittedCount,
contactAddedCount: 0,
visibilitySetCount: 0,
});
this.close();
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to admit some members. Please try again.",
},
5000,
);
}
}
async admitMember(member: {
did: string;
name: string;
member: { memberId: string };
}) {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${member.member.memberId}`,
{ admitted: true },
{ headers },
);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error admitting member:", err);
throw err;
}
}
async addAsContact(member: { did: string; name: string }) {
try {
const newContact = {
did: member.did,
name: member.name,
};
await this.$insertContact(newContact);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error adding contact:", err);
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
// Contact already exists, continue
} else {
throw err; // Re-throw if it's not a duplicate error
}
}
}
async updateContactVisibility(did: string, seesMe: boolean) {
try {
// Get the contact object
const contact = await this.$getContact(did);
if (!contact) {
throw new Error(`Contact not found for DID: ${did}`);
}
// Use the proper API to set visibility on the server
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
contact,
seesMe,
);
if (!result.success) {
throw new Error(result.error || "Failed to set visibility");
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error updating contact visibility:", err);
throw err;
}
}
showContactInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Info",
text: "This user is already your contact, but they are not yet admitted to the meeting.",
},
5000,
);
}
close() {
this.resetSelection();
this.$emit("close");
}
cancel() {
this.close();
}
}
</script>
<style scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: white;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
</style>

175
src/components/MembersList.vue

@ -177,6 +177,16 @@
</div>
</div>
<!-- Admit Pending Members Dialog Component -->
<AdmitPendingMembersDialog
:visible="showAdmitPendingDialog"
:pending-members-data="pendingMembersData"
:active-did="activeDid"
:api-server="apiServer"
@close="closeAdmitPendingDialog"
@success="onAdmitPendingSuccess"
/>
<!-- Set Visibility Dialog Component -->
<SetBulkVisibilityDialog
:visible="showSetVisibilityDialog"
@ -208,6 +218,7 @@ import {
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import SetBulkVisibilityDialog from "./SetBulkVisibilityDialog.vue";
import AdmitPendingMembersDialog from "./AdmitPendingMembersDialog.vue";
interface Member {
admitted: boolean;
@ -225,6 +236,7 @@ interface DecryptedMember {
@Component({
components: {
SetBulkVisibilityDialog,
AdmitPendingMembersDialog,
},
mixins: [PlatformServiceMixin],
})
@ -253,6 +265,17 @@ export default class MembersList extends Vue {
activeDid = "";
apiServer = "";
// Admit Pending Members Dialog state
showAdmitPendingDialog = false;
pendingMembersData: Array<{
did: string;
name: string;
isContact: boolean;
member: { memberId: string };
}> = [];
admitDialogDismissed = false;
isManualRefresh = false;
// Set Visibility Dialog state
showSetVisibilityDialog = false;
visibilityDialogMembers: Array<{
@ -296,8 +319,13 @@ export default class MembersList extends Vue {
// Start auto-refresh
this.startAutoRefresh();
// Check if we should show the visibility dialog on initial load
this.checkAndShowVisibilityDialog();
// Check if we should show the admit pending members dialog first
this.checkAndShowAdmitPendingDialog();
// If no pending members, check for visibility dialog
if (!this.showAdmitPendingDialog) {
this.checkAndShowVisibilityDialog();
}
}
async refreshData() {
@ -305,8 +333,13 @@ export default class MembersList extends Vue {
await this.loadContacts();
await this.fetchMembers();
// Check if we should show the visibility dialog after refresh
this.checkAndShowVisibilityDialog();
// Check if we should show the admit pending members dialog first
this.checkAndShowAdmitPendingDialog();
// If no pending members, check for visibility dialog
if (!this.showAdmitPendingDialog) {
this.checkAndShowVisibilityDialog();
}
}
async fetchMembers() {
@ -463,6 +496,26 @@ export default class MembersList extends Vue {
return this.contacts.find((contact) => contact.did === did);
}
getPendingMembers() {
return this.decryptedMembers
.filter((member) => {
// Exclude the current user
if (member.did === this.activeDid) {
return false;
}
// Only include non-admitted members
return !member.member.admitted;
})
.map((member) => ({
did: member.did,
name: member.name,
isContact: !!this.getContactFor(member.did),
member: {
memberId: member.member.memberId.toString(),
},
}));
}
getMembersForVisibility() {
return this.decryptedMembers
.filter((member) => {
@ -492,7 +545,8 @@ export default class MembersList extends Vue {
* Check if we should show the visibility dialog
* Returns true if there are members for visibility and either:
* - This is the first time (no previous members tracked), OR
* - New members have been added since last check (not removed)
* - New members have been added since last check (not removed), OR
* - This is a manual refresh (isManualRefresh flag is set)
*/
shouldShowVisibilityDialog(): boolean {
const currentMembers = this.getMembersForVisibility();
@ -506,6 +560,11 @@ export default class MembersList extends Vue {
return true;
}
// If this is a manual refresh, always show dialog if there are members
if (this.isManualRefresh) {
return true;
}
// Check if new members have been added (not just any change)
const currentMemberIds = currentMembers.map((m) => m.did);
const previousMemberIds = this.previousVisibilityMembers;
@ -527,6 +586,31 @@ export default class MembersList extends Vue {
this.previousVisibilityMembers = currentMembers.map((m) => m.did);
}
/**
* Check if we should show the admit pending members dialog
*/
shouldShowAdmitPendingDialog(): boolean {
// Don't show if already dismissed
if (this.admitDialogDismissed) {
return false;
}
const pendingMembers = this.getPendingMembers();
return pendingMembers.length > 0;
}
/**
* Show the admit pending members dialog if conditions are met
*/
checkAndShowAdmitPendingDialog() {
if (this.shouldShowAdmitPendingDialog()) {
this.showAdmitPendingDialogMethod();
} else {
// Ensure dialog state is false when no pending members
this.showAdmitPendingDialog = false;
}
}
/**
* Show the visibility dialog if conditions are met
*/
@ -675,6 +759,24 @@ export default class MembersList extends Vue {
}
}
showAdmitPendingDialogMethod() {
// Filter members to show only pending (non-admitted) members
const pendingMembers = this.getPendingMembers();
// Only show dialog if there are pending members
if (pendingMembers.length === 0) {
this.showAdmitPendingDialog = false;
return;
}
// Pause auto-refresh when dialog opens
this.stopAutoRefresh();
// Open the dialog directly
this.pendingMembersData = pendingMembers;
this.showAdmitPendingDialog = true;
}
showSetBulkVisibilityDialog() {
// Filter members to show only those who need visibility set
const membersForVisibility = this.getMembersForVisibility();
@ -682,6 +784,9 @@ export default class MembersList extends Vue {
// Pause auto-refresh when dialog opens
this.stopAutoRefresh();
// Reset manual refresh flag when showing visibility dialog
this.isManualRefresh = false;
// Open the dialog directly
this.visibilityDialogMembers = membersForVisibility;
this.showSetVisibilityDialog = true;
@ -717,28 +822,72 @@ export default class MembersList extends Vue {
}
}
manualRefresh() {
async manualRefresh() {
// Clear existing auto-refresh interval
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
// Trigger immediate refresh and restart timer
this.refreshData();
this.startAutoRefresh();
// Set manual refresh flag
this.isManualRefresh = true;
// Reset the dismissed flag on manual refresh
this.admitDialogDismissed = false;
// Always show dialog on manual refresh if there are members for visibility
if (this.getMembersForVisibility().length > 0) {
this.showSetBulkVisibilityDialog();
// Trigger immediate refresh
await this.refreshData();
// Only start auto-refresh if no dialogs are showing
if (!this.showAdmitPendingDialog && !this.showSetVisibilityDialog) {
this.startAutoRefresh();
}
}
// Admit Pending Members Dialog methods
async closeAdmitPendingDialog() {
this.showAdmitPendingDialog = false;
this.pendingMembersData = [];
this.admitDialogDismissed = true;
// Handle manual refresh flow
if (this.isManualRefresh) {
await this.handleManualRefreshFlow();
this.isManualRefresh = false;
} else {
// Normal flow: refresh data and resume auto-refresh
this.refreshData();
this.startAutoRefresh();
}
}
async handleManualRefreshFlow() {
// Refresh data to reflect any changes made in the admit dialog
await this.refreshData();
// Use the same logic as normal flow to check for visibility dialog
this.checkAndShowVisibilityDialog();
// If no visibility dialog was shown, resume auto-refresh
if (!this.showSetVisibilityDialog) {
this.startAutoRefresh();
}
}
async onAdmitPendingSuccess(_result: {
admittedCount: number;
contactAddedCount: number;
visibilitySetCount: number;
}) {
// After admitting pending members, close the admit dialog
// The visibility dialog will be handled by the closeAdmitPendingDialog flow
await this.closeAdmitPendingDialog();
}
// Set Visibility Dialog methods
closeSetVisibilityDialog() {
this.showSetVisibilityDialog = false;
this.visibilityDialogMembers = [];
// Refresh data when dialog is closed
// Refresh data when dialog is closed to reflect any changes made
this.refreshData();
// Resume auto-refresh when dialog is closed
this.startAutoRefresh();

Loading…
Cancel
Save