Merge pull request 'meeting-project-dialog' (#222) from meeting-project-dialog into master

Reviewed-on: trent_larson/crowd-funder-for-time-pwa#222
This commit is contained in:
2025-11-18 06:56:23 +00:00
3 changed files with 296 additions and 9 deletions

View File

@@ -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>

View File

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

View File

@@ -186,16 +186,59 @@
<div>
<label
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
>
<input
id="projectLink"
v-model="newOrUpdatedMeetingInputs.projectLink"
type="text"
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 class="w-full flex items-stretch">
<div
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"
@click="openProjectLinkDialog"
>
<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>
<button
@@ -224,6 +267,16 @@
</form>
</div>
<MeetingProjectDialog
ref="meetingProjectDialog"
:all-projects="allProjects"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="$notify"
@assign="handleProjectLinkAssigned"
/>
<!-- Members Section -->
<div
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
@@ -292,10 +345,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
didInfo,
} from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@@ -309,6 +365,8 @@ import {
NOTIFY_MEETING_DELETED,
NOTIFY_MEETING_LINK_COPIED,
} from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
interface ServerMeeting {
groupId: number; // from the server
name: string; // to & from the server
@@ -331,6 +389,8 @@ interface MeetingSetupInputs {
QuickNav,
TopMessage,
MembersList,
MeetingProjectDialog,
ProjectIcon,
},
mixins: [PlatformServiceMixin],
})
@@ -354,6 +414,9 @@ export default class OnboardMeetingView extends Vue {
isRegistered = false;
showDeleteConfirm = false;
fullName = "";
allProjects: PlanData[] = [];
allContacts: Contact[] = [];
allMyDids: string[] = [];
get minDateTime() {
const now = new Date();
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.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();
this.isLoading = false;
}
@@ -710,5 +782,97 @@ export default class OnboardMeetingView extends Vue {
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/plans`;
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>