Browse Source

feat(meetings): add project selection dialog for meeting setup

Replace Project Link text input with interactive selection dialog
using new MeetingProjectDialog component. Dialog displays user's
projects with icons and issuer information, following the same
pattern as ProjectRepresentativeDialog.

- Create MeetingProjectDialog with EntityGrid integration
- Add clickable project field with icon, name, and issuer display
- Load projects from /api/v2/report/plansByIssuer endpoint
- Show issuer name instead of handleId for better UX
- Refactor loadProjects to remove unused rowId field
pull/222/head
Jose Olarte III 2 days ago
parent
commit
a5a9af5ddc
  1. 123
      src/components/MeetingProjectDialog.vue
  2. 2
      src/components/ProjectCard.vue
  3. 180
      src/views/OnboardMeetingSetupView.vue

123
src/components/MeetingProjectDialog.vue

@ -0,0 +1,123 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
<!-- EntityGrid for projects -->
<EntityGrid
:entity-type="'projects'"
:entities="allProjects"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'project'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
*
* Features:
* - EntityGrid integration for project selection
* - No special entities (You, Unnamed)
* - Immediate assignment on project selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class MeetingProjectDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Array of available projects */
@Prop({ required: true })
allProjects!: PlanData[];
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** All contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected project and closes the dialog
*/
handleEntitySelected(event: {
type: "person" | "project";
data: Contact | PlanData;
}) {
const project = event.data as PlanData;
this.emitAssign(project);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(project: PlanData): PlanData {
return project;
}
}
</script>
<style scoped></style>

2
src/components/ProjectCard.vue

@ -8,7 +8,7 @@ issuer information. * * @author Matthew Raymer */
> >
<ProjectIcon <ProjectIcon
:entity-id="project.handleId" :entity-id="project.handleId"
:icon-size="48" :icon-size="30"
:image-url="project.image" :image-url="project.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full" class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/> />

180
src/views/OnboardMeetingSetupView.vue

@ -186,16 +186,59 @@
<div> <div>
<label <label
for="projectLink" for="projectLink"
class="block text-sm font-medium text-gray-700" class="block text-sm font-medium text-gray-700 mb-1"
>Project Link</label >Project Link</label
> >
<input <div class="w-full flex items-stretch">
id="projectLink" <div
v-model="newOrUpdatedMeetingInputs.projectLink" class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
type="text" @click="openProjectLinkDialog"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" >
placeholder="Project ID" <div>
/> <ProjectIcon
v-if="selectedProject"
:entity-id="selectedProject.handleId"
:icon-size="30"
:image-url="selectedProject.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome
v-else
icon="folder-open"
class="text-slate-400"
/>
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedProject,
'text-slate-400': !selectedProject,
}"
class="truncate"
>
{{
selectedProject
? selectedProject.name || "Unnamed Project"
: "Select Project…"
}}
</div>
<div
v-if="selectedProject"
class="text-xs text-slate-500 truncate"
>
<font-awesome icon="user" class="text-slate-400" />
{{ selectedProjectIssuerName }}
</div>
</div>
</div>
<button
v-if="selectedProject"
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
@click="unsetProjectLink"
>
<font-awesome icon="trash-can" />
</button>
</div>
</div> </div>
<button <button
@ -224,6 +267,16 @@
</form> </form>
</div> </div>
<MeetingProjectDialog
ref="meetingProjectDialog"
:all-projects="allProjects"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="$notify"
@assign="handleProjectLinkAssigned"
/>
<!-- Members Section --> <!-- Members Section -->
<div <div
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password" v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
@ -292,10 +345,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue"; import MembersList from "../components/MembersList.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import { import {
errorStringForLog, errorStringForLog,
getHeaders, getHeaders,
serverMessageForUser, serverMessageForUser,
didInfo,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto"; import { encryptMessage } from "../libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@ -309,6 +365,8 @@ import {
NOTIFY_MEETING_DELETED, NOTIFY_MEETING_DELETED,
NOTIFY_MEETING_LINK_COPIED, NOTIFY_MEETING_LINK_COPIED,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
interface ServerMeeting { interface ServerMeeting {
groupId: number; // from the server groupId: number; // from the server
name: string; // to & from the server name: string; // to & from the server
@ -331,6 +389,8 @@ interface MeetingSetupInputs {
QuickNav, QuickNav,
TopMessage, TopMessage,
MembersList, MembersList,
MeetingProjectDialog,
ProjectIcon,
}, },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
@ -354,6 +414,9 @@ export default class OnboardMeetingView extends Vue {
isRegistered = false; isRegistered = false;
showDeleteConfirm = false; showDeleteConfirm = false;
fullName = ""; fullName = "";
allProjects: PlanData[] = [];
allContacts: Contact[] = [];
allMyDids: string[] = [];
get minDateTime() { get minDateTime() {
const now = new Date(); const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
@ -370,6 +433,15 @@ export default class OnboardMeetingView extends Vue {
this.fullName = settings?.firstName || ""; this.fullName = settings?.firstName || "";
this.isRegistered = !!settings?.isRegistered; this.isRegistered = !!settings?.isRegistered;
// Load contacts and DIDs for dialog
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
// Load projects
await this.loadProjects();
await this.fetchCurrentMeeting(); await this.fetchCurrentMeeting();
this.isLoading = false; this.isLoading = false;
} }
@ -710,5 +782,97 @@ export default class OnboardMeetingView extends Vue {
this.notify.error("Failed to copy meeting link to clipboard."); this.notify.error("Failed to copy meeting link to clipboard.");
} }
} }
/**
* Load projects from the API
*/
async loadProjects() {
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.apiServer}/api/v2/report/plansByIssuer`;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data.data) {
this.allProjects = resp.data.data.map(
(plan: {
name: string;
description: string;
image?: string;
handleId: string;
issuerDid: string;
}) => ({
name: plan.name,
description: plan.description,
image: plan.image,
handleId: plan.handleId,
issuerDid: plan.issuerDid,
}),
);
}
} catch (error) {
this.$logAndConsole(
"Error loading projects: " + errorStringForLog(error),
true,
);
// Don't show error to user - just leave projects empty
this.allProjects = [];
}
}
/**
* Computed property for selected project
* Derives the project from projectLink by finding it in allProjects
*/
get selectedProject(): PlanData | null {
if (!this.newOrUpdatedMeetingInputs?.projectLink) {
return null;
}
return (
this.allProjects.find(
(p) => p.handleId === this.newOrUpdatedMeetingInputs?.projectLink,
) || null
);
}
/**
* Computed property for selected project issuer display name
* Uses didInfo to format the issuer name similar to ProjectCard
*/
get selectedProjectIssuerName(): string {
if (!this.selectedProject) {
return "";
}
return didInfo(
this.selectedProject.issuerDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
/**
* Open the project link selection dialog
*/
openProjectLinkDialog(): void {
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
}
/**
* Handle project assignment from dialog
*/
handleProjectLinkAssigned(project: PlanData): void {
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
}
}
/**
* Unset the project link and revert to initial state
*/
unsetProjectLink(): void {
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = "";
}
}
} }
</script> </script>

Loading…
Cancel
Save