feat: remove the 'lock' from the giving & receiving sides #229

Merged
trentlarson merged 3 commits from no-locks into master 2025-12-23 03:23:07 +00:00
12 changed files with 44 additions and 145 deletions

View File

@@ -21,7 +21,7 @@ export default defineConfig({
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: 4, workers: 3,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [ reporter: [
['list'], ['list'],

View File

@@ -165,15 +165,19 @@ export default class EntitySelectionStep extends Vue {
*/ */
get stepLabel(): string { get stepLabel(): string {
if (this.stepType === "recipient") { if (this.stepType === "recipient") {
return "Choose who received the gift";
} else if (this.stepType === "giver") {
if (this.shouldShowProjects) { if (this.shouldShowProjects) {
return "Choose a project benefitted from"; return "Choose recipient project";
} else { } else {
return "Choose a person received from"; return "Choose recipient person";
}
} else {
// this.stepType === "giver"
if (this.shouldShowProjects) {
return "Choose giving project";
} else {
return "Choose giving person";
} }
} }
return "Choose entity";
} }
/** /**

View File

@@ -1,16 +1,6 @@
/** * EntitySummaryButton.vue - Displays selected entity with edit capability * /* EntitySummaryButton.vue - Displays selected entity with edit capability */
* Extracted from GiftedDialog.vue to handle entity summary display in the gift *
details step with edit functionality. * * Features: * - Shows entity avatar
(person or project) * - Displays entity name and role label * - Handles editable
vs locked states * - Function props for parent control over edit behavior * -
Supports both person and project entity types * - Template streamlined with
computed CSS properties * * @author Matthew Raymer */
<template> <template>
<component <button :class="containerClasses" @click="handleClick">
:is="editable ? 'button' : 'div'"
:class="containerClasses"
@click="handleClick"
>
<!-- Entity Icon/Avatar --> <!-- Entity Icon/Avatar -->
<div> <div>
<template v-if="entityType === 'project'"> <template v-if="entityType === 'project'">
@@ -47,14 +37,11 @@ computed CSS properties * * @author Matthew Raymer */
</h3> </h3>
</div> </div>
<!-- Edit/Lock Icon --> <!-- Edit Icon -->
<p class="ms-auto text-sm pe-1" :class="iconClasses"> <p class="ms-auto text-sm pe-1 text-blue-500">
<font-awesome <font-awesome icon="pen" title="Change" />
:icon="editable ? 'pen' : 'lock'"
:title="editable ? 'Change' : 'Can\'t be changed'"
/>
</p> </p>
</component> </button>
</template> </template>
<script lang="ts"> <script lang="ts">
@@ -75,12 +62,12 @@ interface EntityData {
} }
/** /**
* EntitySummaryButton - Displays selected entity with optional edit capability * EntitySummaryButton - Displays selected entity with edit capability
* *
* Features: * Features:
* - Shows entity avatar (person or project) * - Shows entity avatar (person or project)
* - Displays entity name and role label * - Displays entity name and role label
* - Handles editable vs locked states * - Always editable - click to change entity
* - Function props for parent control over edit behavior * - Function props for parent control over edit behavior
* - Supports both person and project entity types * - Supports both person and project entity types
* - Template streamlined with computed CSS properties * - Template streamlined with computed CSS properties
@@ -104,13 +91,9 @@ export default class EntitySummaryButton extends Vue {
@Prop({ required: true }) @Prop({ required: true })
label!: string; label!: string;
/** Whether the entity can be edited */
@Prop({ default: true })
editable!: boolean;
/** /**
* Function prop for handling edit requests * Function prop for handling edit requests
* Called when the button is clicked and editable, allowing parent to control edit behavior * Called when the button is clicked, allowing parent to control edit behavior
*/ */
@Prop({ type: Function, default: () => {} }) @Prop({ type: Function, default: () => {} })
onEditRequested!: (data: { onEditRequested!: (data: {
@@ -132,13 +115,6 @@ export default class EntitySummaryButton extends Vue {
return this.entity !== null && "profileImageUrl" in this.entity; return this.entity !== null && "profileImageUrl" in this.entity;
} }
/**
* Computed CSS classes for the edit/lock icon
*/
get iconClasses(): string {
return this.editable ? "text-blue-500" : "text-slate-400";
}
/** /**
* Computed CSS classes for the entity name * Computed CSS classes for the entity name
*/ */
@@ -172,16 +148,13 @@ export default class EntitySummaryButton extends Vue {
} }
/** /**
* Handle click event - only call function prop if editable * Handle click event - call function prop to allow parent to control edit behavior
* Allows parent to control edit behavior and validation
*/ */
handleClick(): void { handleClick(): void {
if (this.editable) { this.onEditRequested({
this.onEditRequested({ entityType: this.entityType,
entityType: this.entityType, entity: this.entity,
entity: this.entity, });
});
}
} }
} }
</script> </script>
@@ -195,8 +168,4 @@ button {
button:hover { button:hover {
background-color: #f1f5f9; /* hover:bg-slate-100 */ background-color: #f1f5f9; /* hover:bg-slate-100 */
} }
div {
cursor: default;
}
</style> </style>

View File

@@ -16,7 +16,6 @@ control over updates and validation * * @author Matthew Raymer */
:entity="giver" :entity="giver"
:entity-type="giverEntityType" :entity-type="giverEntityType"
:label="giverLabel" :label="giverLabel"
:editable="canEditGiver"
:on-edit-requested="handleEditGiver" :on-edit-requested="handleEditGiver"
/> />
@@ -25,7 +24,6 @@ control over updates and validation * * @author Matthew Raymer */
:entity="receiver" :entity="receiver"
:entity-type="recipientEntityType" :entity-type="recipientEntityType"
:label="recipientLabel" :label="recipientLabel"
:editable="canEditRecipient"
:on-edit-requested="handleEditRecipient" :on-edit-requested="handleEditRecipient"
/> />
</div> </div>
@@ -188,14 +186,6 @@ export default class GiftDetailsStep extends Vue {
@Prop({ default: "" }) @Prop({ default: "" })
toProjectId!: string; toProjectId!: string;
/** Whether the giver is locked and cannot be edited */
@Prop({ default: false })
isGiverLocked!: boolean;
/** Whether the recipient is locked and cannot be edited */
@Prop({ default: false })
isRecipientLocked!: boolean;
/** /**
* Function prop for handling description updates * Function prop for handling description updates
* Called when the description input changes, allowing parent to control validation * Called when the description input changes, allowing parent to control validation
@@ -281,21 +271,6 @@ export default class GiftDetailsStep extends Vue {
: "Given to:"; : "Given to:";
} }
/**
* Whether the giver can be edited
*/
get canEditGiver(): boolean {
// If giver is locked via prop, it cannot be edited
return !this.isGiverLocked;
}
/**
* Whether the recipient can be edited
*/
get canEditRecipient(): boolean {
return !this.isRecipientLocked;
}
/** /**
* Computed CSS classes for submit button * Computed CSS classes for submit button
*/ */

View File

@@ -48,8 +48,6 @@
:offer-id="offerId" :offer-id="offerId"
:from-project-id="fromProjectId" :from-project-id="fromProjectId"
:to-project-id="toProjectId" :to-project-id="toProjectId"
:is-giver-locked="isGiverLocked"
:is-recipient-locked="isRecipientLocked"
:on-update-description="(desc: string) => (description = desc)" :on-update-description="(desc: string) => (description = desc)"
:on-update-amount="handleAmountUpdate" :on-update-amount="handleAmountUpdate"
:on-update-unit-code="(code: string) => (unitCode = code)" :on-update-unit-code="(code: string) => (unitCode = code)"
@@ -140,23 +138,11 @@ export default class GiftedDialog extends Vue {
stepType = "giver"; stepType = "giver";
unitCode = "HUR"; unitCode = "HUR";
visible = false; visible = false;
isGiverLocked = false;
isRecipientLocked = false;
libsUtil = libsUtil; libsUtil = libsUtil;
didInfo = didInfo; didInfo = didInfo;
// Computed property to help debug template logic
get shouldShowProjects() {
const result =
(this.stepType === "giver" &&
this.currentGiverEntityType === "project") ||
(this.stepType === "recipient" &&
this.currentRecipientEntityType === "project");
return result;
}
// Computed property to check if current selection would create a conflict // Computed property to check if current selection would create a conflict
get hasPersonConflict() { get hasPersonConflict() {
// Only check for conflicts when both entities are persons // Only check for conflicts when both entities are persons
@@ -265,40 +251,13 @@ export default class GiftedDialog extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity(); const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || ""; this.activeDid = activeIdentity.activeDid || "";
// Determine if entities should be locked // Skip Step 1 if both giver and receiver are provided
// An entity is locked if it's provided as an input property (has did or handleId) const hasGiver = giver && (!!giver.did || !!giver.handleId);
// For persons: locked if did is provided and not "You" (activeDid) const hasReceiver = receiver && (!!receiver.did || !!receiver.handleId);
// For projects: locked if handleId is provided this.firstStep = !hasGiver || !hasReceiver;
// When entities come from ContactsView, ContactGiftingView, or ProjectViewView context, if (this.firstStep) {
// they should be locked if they have a valid identifier (did or handleId) this.stepType = giver ? "receiver" : "giver";
const isGiverProvided = }
giver &&
((giver.did && giver.did !== this.activeDid) ||
(giver.handleId && giver.handleId !== ""));
const isReceiverProvided =
receiver &&
((receiver.did && receiver.did !== this.activeDid) ||
(receiver.handleId && receiver.handleId !== ""));
// Lock entities that are provided (from context or explicitly set)
// This ensures that when entities are chosen from ContactsView, ContactGiftingView,
// or ProjectViewView, the other entity (giver or recipient) that was already set
// from context is locked
this.isGiverLocked = !!isGiverProvided;
this.isRecipientLocked = !!isReceiverProvided;
// Determine if receiver should be locked (for step navigation logic)
// Receiver is locked only if it's provided AND it's not "You" (activeDid)
// "You" is treated as a default that can be changed
const isReceiverLocked =
receiver && receiver.did && receiver.did !== this.activeDid;
// Only skip Step 1 if both giver and receiver are provided AND receiver is locked
// If receiver is "You" (default), still show Step 1 so user can change it
this.firstStep = !(giver && isReceiverLocked);
// If giver is provided but receiver is not locked, start with recipient selection
// Otherwise, start with giver selection
this.stepType = giver && !isReceiverLocked ? "recipient" : "giver";
logger.debug("[GiftedDialog] Settings received:", { logger.debug("[GiftedDialog] Settings received:", {
activeDid: this.activeDid, activeDid: this.activeDid,
@@ -357,9 +316,6 @@ export default class GiftedDialog extends Vue {
this.firstStep = true; this.firstStep = true;
// Reset to initial prop values // Reset to initial prop values
this.currentGiverEntityType = this.initialGiverEntityType; this.currentGiverEntityType = this.initialGiverEntityType;
// Reset lock states
this.isGiverLocked = false;
this.isRecipientLocked = false;
} }
async confirm() { async confirm() {
@@ -692,13 +648,7 @@ export default class GiftedDialog extends Vue {
entityType: string; entityType: string;
currentEntity: { did: string; name: string }; currentEntity: { did: string; name: string };
}) { }) {
// Prevent editing if the entity is locked // Always allow editing - go back to Step 1 to select a new entity
if (data.entityType === "giver" && this.isGiverLocked) {
return;
}
if (data.entityType === "recipient" && this.isRecipientLocked) {
return;
}
this.goBackToStep1(data.entityType); this.goBackToStep1(data.entityType);
} }

View File

@@ -1088,7 +1088,7 @@ export default class ContactsView extends Vue {
{ {
group: "modal", group: "modal",
type: "confirm", type: "confirm",
title: "Delete", title: "Confirm First?",
text: message, text: message,
onNo: async () => { onNo: async () => {
this.showGiftedDialog(giverDid, recipientDid); this.showGiftedDialog(giverDid, recipientDid);

View File

@@ -331,7 +331,7 @@ export default class OfferDetailsView extends Vue {
get recipientAssignmentLabel() { get recipientAssignmentLabel() {
return this.recipientDid return this.recipientDid
? `This is offered to ${this.recipientName}` ? `This is offered to ${this.recipientName}`
: "No recipient was chosen."; : "No named individual recipient was chosen.";
} }
/** /**

View File

@@ -282,9 +282,9 @@ test('Check User 0 can register a random person', async ({ page }) => {
} catch (error) { } catch (error) {
console.log('Could not force close dialog, continuing...'); console.log('Could not force close dialog, continuing...');
} }
// Wait for Person button to be ready - simplified approach // Wait for Thank button to be ready - simplified approach
await page.waitForSelector('button:has-text("Person")', { timeout: 10000 }); await page.waitForSelector('button:has-text("Thank")', { timeout: 10000 });
await page.getByRole('button', { name: 'Person' }).click(); await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click(); await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill('Gave me access!'); await page.getByPlaceholder('What was given').fill('Gave me access!');
await page.getByRole('button', { name: 'Sign & Send' }).click(); await page.getByRole('button', { name: 'Sign & Send' }).click();

View File

@@ -107,7 +107,7 @@ test('Record something given', async ({ page }) => {
return !document.querySelector('.dialog-overlay'); return !document.querySelector('.dialog-overlay');
}, { timeout: 5000 }); }, { timeout: 5000 });
await page.getByRole('button', { name: 'Person' }).click(); await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click(); await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill(finalTitle); await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString()); await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());

View File

@@ -116,7 +116,7 @@ test('Record 9 new gifts', async ({ page }) => {
if (i === 0) { if (i === 0) {
await page.getByTestId('closeOnboardingAndFinish').click(); await page.getByTestId('closeOnboardingAndFinish').click();
} }
await page.getByRole('button', { name: 'Person' }).click(); await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click(); await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill(finalTitles[i]); await page.getByPlaceholder('What was given').fill(finalTitles[i]);
await page.getByRole('spinbutton').fill(finalNumbers[i].toString()); await page.getByRole('spinbutton').fill(finalNumbers[i].toString());

View File

@@ -1,7 +1,8 @@
import { test, expect, Page } from '@playwright/test'; import { test, expect, Page } from '@playwright/test';
import { importUser } from './testUtils'; import { importUser } from './testUtils';
async function testProjectGive(page: Page, selector: string) { async function testProjectGive(page: Page, isToProject: boolean) {
const selector = isToProject ? 'gives-to' : 'gives-from';
// Generate a random string of a few characters // Generate a random string of a few characters
const randomString = Math.random().toString(36).substring(2, 6); const randomString = Math.random().toString(36).substring(2, 6);
@@ -42,9 +43,9 @@ async function testProjectGive(page: Page, selector: string) {
} }
test('Record a give to a project', async ({ page }) => { test('Record a give to a project', async ({ page }) => {
await testProjectGive(page, 'gives-to'); await testProjectGive(page, true);
}); });
test('Record a give from a project', async ({ page }) => { test('Record a give from a project', async ({ page }) => {
await testProjectGive(page, 'gives-from'); await testProjectGive(page, false);
}); });

View File

@@ -117,7 +117,7 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Confirm that home shows contact in "Record Something…" // Confirm that home shows contact in "Record Something…"
await page.goto('./'); await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click(); await page.getByTestId('closeOnboardingAndFinish').click();
await page.getByRole('button', { name: 'Person' }).click(); await page.getByRole('button', { name: 'Thank' }).click();
await expect(page.locator('#sectionGiftedGiver').getByRole('listitem').filter({ hasText: contactName })).toBeVisible(); await expect(page.locator('#sectionGiftedGiver').getByRole('listitem').filter({ hasText: contactName })).toBeVisible();
// Record something given by new contact // Record something given by new contact