Browse Source

Merge pull request 'offer editing' (#123) from offer-edit into master

Reviewed-on: https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/pulls/123
playwright-pwa-install-test
trentlarson 3 months ago
parent
commit
5811dacb84
  1. 2
      src/components/GiftedDialog.vue
  2. 45
      src/components/OfferDialog.vue
  3. 173
      src/libs/endorserServer.ts
  4. 7
      src/router/index.ts
  5. 65
      src/views/ClaimView.vue
  6. 9
      src/views/ContactsView.vue
  7. 7
      src/views/GiftedDetailsView.vue
  8. 2
      src/views/NewEditProjectView.vue
  9. 633
      src/views/OfferDetailsView.vue
  10. 6
      src/views/ProjectViewView.vue
  11. 37
      test-playwright/50-record-offer.spec.ts

2
src/components/GiftedDialog.vue

@ -55,7 +55,7 @@
}" }"
class="text-blue-500" class="text-blue-500"
> >
Photo & Details ... Photo & more options ...
</router-link> </router-link>
</span> </span>
</div> </div>

45
src/components/OfferDialog.vue

@ -36,18 +36,27 @@
<fa icon="chevron-right" /> <fa icon="chevron-right" />
</div> </div>
</div> </div>
<div class="flex flex-row mt-2"> <div class="mt-4 flex justify-center">
<span <span>
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2" <router-link
> :to="{
Expiration name: 'offer-details',
query: {
amountInput,
description,
offererDid: activeDid,
projectId,
projectName,
recipientDid,
recipientName,
unitCode: amountUnitCode,
},
}"
class="text-blue-500"
>
Conditions & more options...
</router-link>
</span> </span>
<input
type="text"
class="w-full border border-slate-400 px-2 py-2 rounded-r"
:placeholder="datePlaceholder()"
v-model="expirationDateInput"
/>
</div> </div>
<p class="text-center mt-6 mb-2 italic"> <p class="text-center mt-6 mb-2 italic">
Sign & Send to publish to the world Sign & Send to publish to the world
@ -71,7 +80,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { DateTime } from "luxon";
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
@ -84,7 +92,8 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
export default class OfferDialog extends Vue { export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop projectId? = ""; @Prop projectId?;
@Prop projectName?;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
@ -94,13 +103,15 @@ export default class OfferDialog extends Vue {
description = ""; description = "";
expirationDateInput = ""; expirationDateInput = "";
recipientDid? = ""; recipientDid? = "";
recipientName? = "";
visible = false; visible = false;
libsUtil = libsUtil; libsUtil = libsUtil;
async open(recipientDid?: string) { async open(recipientDid?: string, recipientName?: string) {
try { try {
this.recipientDid = recipientDid; this.recipientDid = recipientDid;
this.recipientName = recipientName;
await db.open(); await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
@ -146,12 +157,6 @@ export default class OfferDialog extends Vue {
)}`; )}`;
} }
datePlaceholder() {
return (
"Date, eg. " + DateTime.now().plus({ month: 1 }).toISO().slice(0, 10)
);
}
cancel() { cancel() {
this.close(); this.close();
this.eraseValues(); this.eraseValues();

173
src/libs/endorserServer.ts

@ -48,7 +48,7 @@ export interface ClaimResult {
} }
export interface GenericVerifiableCredential { export interface GenericVerifiableCredential {
"@context"?: string; "@context"?: string; // optional when embedded, eg. in an Agree
"@type": string; "@type": string;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
} }
@ -62,6 +62,7 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
id: string; id: string;
issuedAt: string; issuedAt: string;
issuer: string; issuer: string;
publicUrls?: Record<string, string>; // only for IDs that want to be public
} }
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> = export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{ {
@ -139,13 +140,13 @@ export interface GiveVerifiableCredential extends GenericVerifiableCredential {
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8 // https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential { export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree "@context"?: string; // optional when embedded... though it doesn't make sense to agree to an offer
"@type": "Offer"; "@type": "Offer";
description?: string; description?: string; // conditions for the offer
includesObject?: { amountOfThisGood: number; unitCode: string }; includesObject?: { amountOfThisGood: number; unitCode: string };
itemOffered?: { itemOffered?: {
description?: string; description?: string; // description of the item
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string }; isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
}; };
offeredBy?: { identifier: string }; offeredBy?: { identifier: string };
@ -155,7 +156,7 @@ export interface OfferVerifiableCredential {
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7 // https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential { export interface PlanVerifiableCredential extends GenericVerifiableCredential {
"@context": "https://schema.org"; "@context": "https://schema.org";
"@type": "PlanAction"; "@type": "PlanAction";
name: string; name: string;
@ -563,6 +564,8 @@ export async function setPlanInCache(
/** /**
* Construct GiveAction VC for submission to server * Construct GiveAction VC for submission to server
*
* @param lastClaimId supplied when editing a previous claim
*/ */
export function hydrateGive( export function hydrateGive(
vcClaimOrig?: GiveVerifiableCredential, vcClaimOrig?: GiveVerifiableCredential,
@ -587,6 +590,7 @@ export function hydrateGive(
}; };
if (lastClaimId) { if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId; vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier; delete vcClaim.identifier;
} }
@ -594,16 +598,17 @@ export function hydrateGive(
vcClaim.agent = fromDid ? { identifier: fromDid } : undefined; vcClaim.agent = fromDid ? { identifier: fromDid } : undefined;
vcClaim.recipient = toDid ? { identifier: toDid } : undefined; vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
vcClaim.description = description || undefined; vcClaim.description = description || undefined;
vcClaim.object = amount vcClaim.object =
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" } amount && !isNaN(amount)
: undefined; ? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
// ensure fulfills is an array // ensure fulfills is an array
if (!Array.isArray(vcClaim.fulfills)) { if (!Array.isArray(vcClaim.fulfills)) {
vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : []; vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [];
} }
// ... and replace or add each element, ending with Trade or Donate // ... and replace or add each element, ending with Trade or Donate
// I realize this doesn't change any elements that are not PlanAction or Offer or Trade/Action. // I realize the following doesn't change any elements that are not PlanAction or Offer or Trade/Action.
vcClaim.fulfills = vcClaim.fulfills.filter( vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "PlanAction", (elem) => elem["@type"] !== "PlanAction",
); );
@ -639,8 +644,8 @@ export function hydrateGive(
* *
* @param fromDid may be null * @param fromDid may be null
* @param toDid * @param toDid
* @param description may be null; should have this or amount * @param description may be null
* @param amount may be null; should have this or description * @param amount may be null
*/ */
export async function createAndSubmitGive( export async function createAndSubmitGive(
axios: Axios, axios: Axios,
@ -667,6 +672,7 @@ export async function createAndSubmitGive(
fulfillsOfferHandleId, fulfillsOfferHandleId,
isTrade, isTrade,
imageUrl, imageUrl,
undefined,
); );
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as GenericVerifiableCredential, vcClaim as GenericVerifiableCredential,
@ -680,9 +686,9 @@ export async function createAndSubmitGive(
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
* *
* @param fromDid may be null * @param fromDid may be null
* @param toDid * @param toDid may be null if project is provided
* @param description may be null; should have this or amount * @param description may be null
* @param amount may be null; should have this or description * @param amount may be null
*/ */
export async function editAndSubmitGive( export async function editAndSubmitGive(
axios: Axios, axios: Axios,
@ -720,51 +726,128 @@ export async function editAndSubmitGive(
); );
} }
/**
* Construct Offer VC for submission to server
*
* @param lastClaimId supplied when editing a previous claim
*/
export function hydrateOffer(
vcClaimOrig?: OfferVerifiableCredential,
fromDid?: string,
toDid?: string,
itemDescription?: string,
amount?: number,
unitCode?: string,
conditionDescription?: string,
fulfillsProjectHandleId?: string,
validThrough?: string,
lastClaimId?: string,
): OfferVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim: OfferVerifiableCredential = vcClaimOrig
? R.clone(vcClaimOrig)
: {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "Offer",
};
if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier;
}
vcClaim.offeredBy = fromDid ? { identifier: fromDid } : undefined;
vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
vcClaim.description = conditionDescription || undefined;
vcClaim.includesObject =
amount && !isNaN(amount)
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
if (itemDescription || fulfillsProjectHandleId) {
vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.description = itemDescription || undefined;
if (fulfillsProjectHandleId) {
vcClaim.itemOffered.isPartOf = {
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
};
}
}
vcClaim.validThrough = validThrough || undefined;
return vcClaim;
}
/** /**
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
* *
* @param identity * @param identity
* @param description may be null; should have this or amount * @param description may be null
* @param amount may be null; should have this or description * @param amount may be null
* @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null) * @param validThrough ISO 8601 date string YYYY-MM-DD (may be null)
* @param fulfillsProjectHandleId ID of project to which this contributes (may be null) * @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
*/ */
export async function createAndSubmitOffer( export async function createAndSubmitOffer(
axios: Axios, axios: Axios,
apiServer: string, apiServer: string,
issuerDid: string, issuerDid: string,
description?: string, itemDescription: string,
amount?: number, amount?: number,
unitCode?: string, unitCode?: string,
expirationDate?: string, conditionDescription?: string,
validThrough?: string,
recipientDid?: string, recipientDid?: string,
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {
const vcClaim: OfferVerifiableCredential = { const vcClaim = hydrateOffer(
"@context": SCHEMA_ORG_CONTEXT, undefined,
"@type": "Offer", issuerDid,
offeredBy: { identifier: issuerDid }, recipientDid,
validThrough: expirationDate || undefined, itemDescription,
}; amount,
if (amount) { unitCode,
vcClaim.includesObject = { conditionDescription,
amountOfThisGood: amount, fulfillsProjectHandleId,
unitCode: unitCode || "HUR", validThrough,
}; undefined,
} );
if (description) { return createAndSubmitClaim(
vcClaim.itemOffered = { description }; vcClaim as OfferVerifiableCredential,
} issuerDid,
if (recipientDid) { apiServer,
vcClaim.recipient = { identifier: recipientDid }; axios,
} );
if (fulfillsProjectHandleId) { }
vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.isPartOf = { export async function editAndSubmitOffer(
"@type": "PlanAction", axios: Axios,
identifier: fulfillsProjectHandleId, apiServer: string,
}; fullClaim: GenericCredWrapper<OfferVerifiableCredential>,
} issuerDid: string,
itemDescription: string,
amount?: number,
unitCode?: string,
conditionDescription?: string,
validThrough?: string,
recipientDid?: string,
fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim = hydrateOffer(
fullClaim.claim,
issuerDid,
recipientDid,
itemDescription,
amount,
unitCode,
conditionDescription,
fulfillsProjectHandleId,
validThrough,
fullClaim.id,
);
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as OfferVerifiableCredential, vcClaim as OfferVerifiableCredential,
issuerDid, issuerDid,

7
src/router/index.ts

@ -91,7 +91,7 @@ const routes: Array<RouteRecordRaw> = [
{ {
path: "/gifted-details", path: "/gifted-details",
name: "gifted-details", name: "gifted-details",
component: () => import("../views/GiftedDetails.vue"), component: () => import("@/views/GiftedDetailsView.vue"),
}, },
{ {
path: "/help", path: "/help",
@ -143,6 +143,11 @@ const routes: Array<RouteRecordRaw> = [
name: "new-identifier", name: "new-identifier",
component: () => import("../views/NewIdentifierView.vue"), component: () => import("../views/NewIdentifierView.vue"),
}, },
{
path: "/offer-details/:id?",
name: "offer-details",
component: () => import("../views/OfferDetailsView.vue"),
},
{ {
path: "/project/:id?", path: "/project/:id?",
name: "project", name: "project",

65
src/views/ClaimView.vue

@ -24,13 +24,15 @@
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }} {{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
<button <button
v-if=" v-if="
veriClaim.claimType === 'GiveAction' && ['GiveAction', 'Offer'].includes(
veriClaim.issuer === activeDid veriClaim.claimType as string,
) && veriClaim.issuer === activeDid
" "
@click="onClickEditClaim" @click="onClickEditClaim"
title="Edit" title="Edit"
data-testId="editClaimButton"
> >
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1"></fa> <fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button> </button>
</h2> </h2>
<div class="text-sm"> <div class="text-sm">
@ -49,11 +51,11 @@
</button> </button>
<span v-show="showIdCopy">Copied ID</span> <span v-show="showIdCopy">Copied ID</span>
</div> </div>
<div> <div data-testId="description">
<fa icon="message" class="fa-fw text-slate-400" /> <fa icon="message" class="fa-fw text-slate-400" />
{{ {{
veriClaim.claim?.description || veriClaim.claim?.itemOffered?.description ||
veriClaim.claim?.itemOffered?.description veriClaim.claim?.description
}} }}
</div> </div>
<div> <div>
@ -402,7 +404,7 @@
<!-- Keep the dump contents directly between > and < to avoid weird spacing. --> <!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre <pre
v-if="showVeriClaimDump" v-if="showVeriClaimDump"
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ veriClaimDump }}</pre >{{ veriClaimDump }}</pre
> >
</div> </div>
@ -425,7 +427,10 @@
</button> </button>
</div> </div>
<div v-else> <div v-else>
<pre>{{ fullClaimDump }}</pre> <pre
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ fullClaimDump }}</pre
>
</div> </div>
<a <a
@ -843,15 +848,41 @@ export default class ClaimView extends Vue {
} }
onClickEditClaim() { onClickEditClaim() {
const route = { if (this.veriClaim.claimType === "GiveAction") {
name: "gifted-details", const route = {
query: { name: "gifted-details",
prevCredToEdit: JSON.stringify(this.veriClaim), query: {
destinationPathAfter: prevCredToEdit: JSON.stringify(this.veriClaim),
"/claim/" + encodeURIComponent(this.veriClaim.handleId), destinationPathAfter:
}, "/claim/" + encodeURIComponent(this.veriClaim.handleId),
}; },
(this.$router as Router).push(route); };
(this.$router as Router).push(route);
} else if (this.veriClaim.claimType === "Offer") {
const route = {
name: "offer-details",
query: {
prevCredToEdit: JSON.stringify(this.veriClaim),
destinationPathAfter:
"/claim/" + encodeURIComponent(this.veriClaim.handleId),
},
};
(this.$router as Router).push(route);
} else {
console.error(
"Unrecognized claim type for edit:",
this.veriClaim.claimType,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "This is an unrecognized claim type.",
},
3000,
);
}
} }
} }
</script> </script>

9
src/views/ContactsView.vue

@ -271,7 +271,7 @@
<button <button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-blue-400" class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-blue-400"
@click="openOfferDialog(contact.did)" @click="openOfferDialog(contact.did, contact.name)"
> >
Offer Offer
</button> </button>
@ -1131,8 +1131,11 @@ export default class ContactsView extends Vue {
); );
} }
openOfferDialog(recipientDid: string) { openOfferDialog(recipientDid: string, recipientName: string) {
(this.$refs.customOfferDialog as OfferDialog).open(recipientDid); (this.$refs.customOfferDialog as OfferDialog).open(
recipientDid,
recipientName,
);
} }
private async onClickCancelName() { private async onClickCancelName() {

7
src/views/GiftedDetails.vue → src/views/GiftedDetailsView.vue

@ -269,6 +269,7 @@ export default class GiftedDetails extends Vue {
this.hideBackButton = this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true"; (this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || ""; this.message = ((this.$route as Router).query["message"] as string) || "";
// find any offer ID // find any offer ID
const fulfills = this.prevCredToEdit?.claim?.fulfills; const fulfills = this.prevCredToEdit?.claim?.fulfills;
const fulfillsArray = Array.isArray(fulfills) const fulfillsArray = Array.isArray(fulfills)
@ -351,6 +352,7 @@ export default class GiftedDetails extends Vue {
); );
} }
} }
// these should be functions but something's wrong with the syntax in the <> conditional
this.givenToProject = !!this.projectId; this.givenToProject = !!this.projectId;
this.givenToRecipient = !this.givenToProject && !!this.recipientDid; this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
@ -549,7 +551,7 @@ export default class GiftedDetails extends Vue {
group: "alert", group: "alert",
type: "warning", type: "warning",
title: "Error", title: "Error",
text: "To assign to a project, you must open this dialog through a project.", text: "To assign to a project, you must open this page through a project.",
}, },
3000, 3000,
); );
@ -574,7 +576,7 @@ export default class GiftedDetails extends Vue {
group: "alert", group: "alert",
type: "warning", type: "warning",
title: "Error", title: "Error",
text: "To assign to a recipient, you must open this dialog from a contact.", text: "To assign to a recipient, you must open this page from a contact.",
}, },
3000, 3000,
); );
@ -694,7 +696,6 @@ export default class GiftedDetails extends Vue {
constructGiveParam() { constructGiveParam() {
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined; const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
const projectId = this.givenToProject ? this.projectId : undefined; const projectId = this.givenToProject ? this.projectId : undefined;
// const giveClaim = constructGive(
const giveClaim = hydrateGive( const giveClaim = hydrateGive(
this.prevCredToEdit?.claim as GiveVerifiableCredential, this.prevCredToEdit?.claim as GiveVerifiableCredential,
this.giverDid, this.giverDid,

2
src/views/NewEditProjectView.vue

@ -97,8 +97,8 @@
/> />
<input <input
:disabled="!startDateInput" :disabled="!startDateInput"
v-model="startTimeInput"
placeholder="Start Time" placeholder="Start Time"
v-model="startTimeInput"
type="time" type="time"
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2" class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
/> />

633
src/views/OfferDetailsView.vue

@ -0,0 +1,633 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div
v-if="!hideBackButton"
class="text-lg text-center font-light relative px-7"
>
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Was Offered</h1>
<h1 class="text-xl font-bold text-center mb-4">
<span>
Offer to
{{
offeredToProject
? projectName
: offeredToRecipient
? recipientName
: "someone unidentified"
}}</span
>
</h1>
<textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was offered"
v-model="itemDescription"
data-testId="itemDescription"
/>
<div class="flex flex-row justify-center">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
data-testId="inputOfferAmount"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<div class="flex flex-row mt-2">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
Conditions
</span>
<textarea
class="w-full border border-slate-400 px-3 py-2 rounded-r"
placeholder="Prerequisites, other people to include, etc."
v-model="conditionDescription"
/>
</div>
<div class="flex flex-row mt-2">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
{{ validThroughDateInput ? "" : "No" }}&nbsp;Expiration
</span>
<input
v-model="validThroughDateInput"
type="date"
class="w-full rounded border border-slate-400 px-3 py-2 rounded-r"
/>
</div>
<div class="h-7 mt-4 flex">
<input
v-if="projectId && !offeredToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="offeredToProject"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfProject()"
/>
<label class="text-sm mt-1">
{{
projectId
? "This was given to " + projectName
: "No project was chosen"
}}
</label>
</div>
<div class="h-7 mt-4 flex">
<input
v-if="recipientDid && !offeredToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="offeredToRecipient"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfRecipient()"
/>
<label class="text-sm mt-1">
{{
recipientDid
? "This was given to " + recipientName
: "No recipient was chosen."
}}
</label>
</div>
<div class="mt-4 flex">
<router-link
:to="{
name: 'claim-add-raw',
query: {
claim: constructOfferParam(),
},
}"
class="text-blue-500"
>
Edit & Submit Raw
</router-link>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
/>
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="confirm"
>
Sign &amp; Send
</button>
<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-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import {
createAndSubmitOffer,
didInfo,
editAndSubmitOffer,
GenericCredWrapper,
getPlanFromCache,
hydrateOffer,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { Contact } from "@/db/tables/contacts";
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class OfferDetailsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
amountInput = "0";
conditionDescription = "";
itemDescription = "";
destinationPathAfter = "";
offeredToProject = false;
offeredToRecipient = false;
offererDid: string | undefined;
hideBackButton = false;
message = "";
offerId = "";
prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>;
projectId = "";
projectName = "a project";
recipientDid = "";
recipientName = "";
unitCode = "HUR";
validThroughDateInput = "";
libsUtil = libsUtil;
async mounted() {
try {
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
? (JSON.parse(
(this.$route as Router).query["prevCredToEdit"],
) as GenericCredWrapper<OfferVerifiableCredential>)
: undefined;
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Retrieval Error",
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
},
6000,
);
}
const prevAmount =
this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood;
this.amountInput =
(this.$route as Router).query["amountInput"] ||
(prevAmount ? String(prevAmount) : "") ||
this.amountInput;
this.unitCode = ((this.$route as Router).query["unitCode"] ||
this.prevCredToEdit?.claim?.includesObject?.unitCode ||
this.unitCode) as string;
this.conditionDescription =
this.prevCredToEdit?.claim?.description || this.conditionDescription;
this.itemDescription =
(this.$route as Router).query["description"] ||
this.prevCredToEdit?.claim?.itemOffered?.description ||
this.itemDescription;
this.destinationPathAfter = (this.$route as Router).query[
"destinationPathAfter"
];
this.offererDid = ((this.$route as Router).query["offererDid"] ||
this.prevCredToEdit?.claim?.agent?.identifier ||
this.offererDid) as string;
this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || "";
// find any project ID
let project;
if (
this.prevCredToEdit?.claim?.itemOffered?.isPartOf["@type"] ===
"PlanAction"
) {
project = this.prevCredToEdit?.claim?.itemOffered?.isPartOf;
}
this.projectId = ((this.$route as Router).query["projectId"] ||
project?.identifier ||
this.projectId) as string;
this.projectName = ((this.$route as Router).query["projectName"] ||
project?.name ||
this.projectName) as string;
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
this.recipientName =
((this.$route as Router).query["recipientName"] as string) || "";
this.validThroughDateInput =
this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput;
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
let allContacts: Contact[] = [];
let allMyDids: string[] = [];
if (this.recipientDid && !this.recipientName) {
allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
allMyDids = allAccounts.map((acc) => acc.did);
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
// these should be functions but something's wrong with the syntax in the <> conditional
this.offeredToProject = !!this.projectId;
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
if (this.projectId && !this.projectName) {
// console.log("Getting project name from cache", this.projectId);
const project = await getPlanFromCache(
this.projectId,
this.axios,
this.apiServer,
this.activeDid,
);
this.projectName = project?.name
? "the project: " + project.name
: "a project";
}
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
cancelBack() {
(this.$router as Router).back();
}
async confirm() {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a offer.",
},
2000,
);
return;
}
if (parseFloat(this.amountInput) < 0) {
this.$notify(
{
group: "alert",
type: "danger",
text: "You may not send a negative number.",
title: "",
},
2000,
);
return;
}
if (!this.itemDescription && !parseFloat(this.amountInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`,
},
2000,
);
return;
}
this.$notify(
{
group: "alert",
type: "toast",
text: "Recording the give...",
title: "",
},
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
await this.recordOffer();
}
notifyUserOfProject() {
if (!this.projectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a project, you must open this page through a project.",
},
3000,
);
} else {
// must be because offeredToRecipient is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a project and to a recipient.",
},
3000,
);
}
}
notifyUserOfRecipient() {
if (!this.recipientDid) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a recipient, you must open this page from a contact.",
},
3000,
);
} else {
// must be because offeredToProject is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a recipient and to a project.",
},
3000,
);
}
}
/**
*
* @param offererDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordOffer() {
try {
const recipientDid = this.offeredToRecipient
? this.recipientDid
: undefined;
const projectId = this.offeredToProject ? this.projectId : undefined;
let result;
if (this.prevCredToEdit) {
// don't create from a blank one in case some properties were set from a different interface
result = await editAndSubmitOffer(
this.axios,
this.apiServer,
this.prevCredToEdit,
this.activeDid,
this.itemDescription,
parseFloat(this.amountInput),
this.unitCode,
this.conditionDescription,
this.validThroughDateInput,
recipientDid,
projectId,
);
} else {
result = await createAndSubmitOffer(
this.axios,
this.apiServer,
this.activeDid,
this.itemDescription,
parseFloat(this.amountInput),
this.unitCode,
this.conditionDescription,
this.validThroughDateInput,
recipientDid,
projectId,
);
}
if (result.type === "error" || this.isCreationError(result.response)) {
const errorMessage = this.getCreationErrorMessage(result);
console.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the give.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: `That offer was recorded.`,
},
5000,
);
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the give.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
-1,
);
}
}
constructOfferParam() {
const recipientDid = this.offeredToRecipient
? this.recipientDid
: undefined;
const projectId = this.offeredToProject ? this.projectId : undefined;
const giveClaim = hydrateOffer(
this.prevCredToEdit?.claim as OfferVerifiableCredential,
this.activeDid,
recipientDid,
this.itemDescription,
parseFloat(this.amountInput),
this.unitCode,
this.conditionDescription,
projectId,
this.validThroughDateInput,
this.prevCredToEdit?.id as string,
);
const claimStr = JSON.stringify(giveClaim);
return claimStr;
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{
group: "alert",
type: "success",
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
);
}
}
</script>

6
src/views/ProjectViewView.vue

@ -170,7 +170,11 @@
</button> </button>
</div> </div>
</div> </div>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" /> <OfferDialog
ref="customOfferDialog"
:projectId="this.projectId"
:projectName="this.name"
/>
<div v-if="activeDid && isRegistered"> <div v-if="activeDid && isRegistered">
<div class="text-center"> <div class="text-center">

37
test-playwright/50-record-offer.spec.ts

@ -6,7 +6,7 @@ test('Record an offer', async ({ page }) => {
const randomString = Math.random().toString(36).substring(2, 8); const randomString = Math.random().toString(36).substring(2, 8);
// Standard title prefix // Standard title prefix
const finalTitle = `Offering of ${randomString}`; const finalTitle = `Offering of ${randomString}`;
const randomNonZeroNumber = Math.floor(Math.random() * 999) + 1; const randomNonZeroNumber = Math.floor(Math.random() * 998) + 1;
// Create new ID for default user // Create new ID for default user
await importUser(page); await importUser(page);
@ -22,12 +22,41 @@ test('Record an offer', async ({ page }) => {
await page.getByRole('button', { name: 'Sign & Send' }).click(); await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That offer was recorded.')).toBeVisible(); await expect(page.getByText('That offer was recorded.')).toBeVisible();
// Refresh home view and check gift // go to the offer and check the values
await page.goto('./projects'); await page.goto('./projects');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').first().click(); await page.locator('li').filter({ hasText: finalTitle }).locator('a').first().click();
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible(); await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
const page1Promise = page.waitForEvent('popup'); const serverPagePromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'View on the Public Server' }).click(); await page.getByRole('link', { name: 'View on the Public Server' }).click();
const page1 = await page1Promise; const serverPage = await serverPagePromise;
await serverPage.getByText(finalTitle);
await serverPage.getByText('did:none:HIDDEN');
// Now update that offer
// find the edit page and check the old values again
await page.goto('./projects');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').first().click();
await page.getByTestId('editClaimButton').click();
await page.locator('heading', { hasText: 'What was offered' }).isVisible();
const itemDesc = await page.getByTestId('itemDescription');
await expect(itemDesc).toHaveValue(finalTitle);
const amount = await page.getByTestId('inputOfferAmount');
await expect(amount).toHaveValue(randomNonZeroNumber.toString());
// update the values
await itemDesc.fill('Updated ' + finalTitle);
await amount.fill(String(randomNonZeroNumber + 1));
await page.getByRole('button', { name: 'Sign & Send' }).click();
// go to the offer claim again and check the updated values
await page.goto('./projects');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').first().click();
const newItemDesc = await page.getByTestId('description');
await expect(newItemDesc).toHaveText(finalTitle);
// go to edit page
await page.getByTestId('editClaimButton').click();
const newAmount = await page.getByTestId('inputOfferAmount');
await expect(newAmount).toHaveValue(randomNonZeroNumber.toString());
}); });
Loading…
Cancel
Save