-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Share & Embed assignment (#114)
* Add model, api for embedded sessions * Keep one document, only one session * Support one user id with multiple otp * return to Home if no token for secure path * Add Share & Embed section in Assignment Body * Show popup when inactive session * Add preview embed and share assignment * Add Responses List and export csv * Update UI of Reponses Tab * Add embedded assignment flow * fix css UserBasicInformation * Link submission into form response * Send feedback to student in embed
- Loading branch information
1 parent
8e5b2c8
commit d7a286b
Showing
38 changed files
with
2,582 additions
and
317 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
128 changes: 128 additions & 0 deletions
128
...Document/DocumentBody/CoverPage/AssignmentCoverPageBody/ShareAssignment/FormResponses.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
import useDocumentStore from "store/DocumentStore"; | ||
import { useQuery } from "@apollo/client"; | ||
import { Button, Heading, Table, Text } from "@radix-ui/themes"; | ||
import { Trans } from "@lingui/macro"; | ||
import { CSVLink } from "react-csv"; | ||
import React from "react"; | ||
import Link from "next/link"; | ||
|
||
import { GET_EMBEDDED_RESPONSES } from "graphql/query/DocumentQuery"; | ||
import { handleError } from "graphql/ApolloClient"; | ||
import { | ||
GetEmbeddedResponses, | ||
GetEmbeddedResponses_documentGetEmbeddedSession_responses, | ||
} from "graphql/types"; | ||
import { formatTimestamp, FormatType, getNowAsSec } from "util/Time"; | ||
import { formatDocumentRoute } from "config/Routes"; | ||
|
||
const FormResponses = () => { | ||
const title = useDocumentStore((state) => state.activeDocument?.title); | ||
const documentId = useDocumentStore((state) => state.activeDocumentId); | ||
const { data } = useQuery<GetEmbeddedResponses>(GET_EMBEDDED_RESPONSES, { | ||
onError: handleError, | ||
variables: { | ||
documentId, | ||
}, | ||
}); | ||
|
||
const responses = data?.documentGetEmbeddedSession?.responses || []; | ||
return ( | ||
<div> | ||
<div style={{ display: "flex", justifyContent: "space-between" }}> | ||
<div> | ||
<Heading> | ||
<Trans>Form Responses</Trans> | ||
</Heading> | ||
<Text color="gray" size="2"> | ||
<Trans> | ||
The response after a student fills out the form before doing the | ||
assignment via the Share & Embed feature. | ||
</Trans> | ||
</Text> | ||
</div> | ||
<ExportResponses title={title} responses={responses} /> | ||
</div> | ||
<Table.Root> | ||
<Table.Header> | ||
<Table.Row> | ||
<Table.ColumnHeaderCell> | ||
<Trans>Full name</Trans> | ||
</Table.ColumnHeaderCell> | ||
<Table.ColumnHeaderCell> | ||
<Trans>Email</Trans> | ||
</Table.ColumnHeaderCell> | ||
<Table.ColumnHeaderCell> | ||
<Trans>Phone number</Trans> | ||
</Table.ColumnHeaderCell> | ||
<Table.ColumnHeaderCell> | ||
<Trans>Response at</Trans> | ||
</Table.ColumnHeaderCell> | ||
<Table.ColumnHeaderCell /> | ||
</Table.Row> | ||
</Table.Header> | ||
|
||
<Table.Body> | ||
{responses.map((response) => ( | ||
<Table.Row key={response.submissionId}> | ||
<Table.RowHeaderCell> | ||
{response.responseData.firstName}{" "} | ||
{response.responseData.lastName} | ||
</Table.RowHeaderCell> | ||
<Table.Cell>{response.responseData.email}</Table.Cell> | ||
<Table.Cell>{response.responseData.phoneNumber}</Table.Cell> | ||
<Table.Cell> | ||
{formatTimestamp(response.createdAt, FormatType.DateTimeFormat)} | ||
</Table.Cell> | ||
<Table.Cell> | ||
{response.submission && ( | ||
<Link | ||
href={formatDocumentRoute(response.submission?.documentId)} | ||
target="_blank" | ||
passHref | ||
> | ||
<a target="_blank"> | ||
<Button variant="soft">View submission</Button> | ||
</a> | ||
</Link> | ||
)} | ||
</Table.Cell> | ||
</Table.Row> | ||
))} | ||
</Table.Body> | ||
</Table.Root> | ||
</div> | ||
); | ||
}; | ||
|
||
type ExportResponsesProps = { | ||
title: string; | ||
responses: GetEmbeddedResponses_documentGetEmbeddedSession_responses[]; | ||
}; | ||
|
||
const ExportResponses = ({ title, responses }: ExportResponsesProps) => { | ||
const today = formatTimestamp(getNowAsSec()); | ||
|
||
const csvData = [["Name", "Email", "Phone Number", "Response at"]]; | ||
responses.forEach((response) => { | ||
csvData.push([ | ||
response.responseData.firstName, | ||
response.responseData.email, | ||
response.responseData.phoneNumber, | ||
formatTimestamp(response.createdAt, FormatType.DateTimeFormat), | ||
]); | ||
}); | ||
|
||
return ( | ||
<CSVLink | ||
data={csvData} | ||
target={"_blank"} | ||
filename={`responses_${title}_${today}.csv`} | ||
> | ||
<Button> | ||
<Trans>Export to CSV</Trans> | ||
</Button> | ||
</CSVLink> | ||
); | ||
}; | ||
|
||
export default FormResponses; |
200 changes: 200 additions & 0 deletions
200
...ponents/Document/DocumentBody/CoverPage/AssignmentCoverPageBody/ShareAssignment/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
import { | ||
Button, | ||
Heading, | ||
Separator, | ||
Switch, | ||
Text, | ||
TextArea, | ||
TextField, | ||
} from "@radix-ui/themes"; | ||
import { t, Trans } from "@lingui/macro"; | ||
import { Link1Icon } from "@radix-ui/react-icons"; | ||
import { useMutation, useQuery } from "@apollo/client"; | ||
import copy from "copy-to-clipboard"; | ||
import toast from "react-hot-toast"; | ||
|
||
import { GET_DOCUMENT_EMBED_SESSION } from "graphql/query/DocumentQuery"; | ||
import { useEffect, useState } from "react"; | ||
import { | ||
EmbeddedType, | ||
GetDocumentEmbedSession, | ||
UpsertDocumentEmbedSession, | ||
} from "graphql/types"; | ||
import Loading from "components/Loading"; | ||
import { formatShareDocument } from "config/Routes"; | ||
import useDocumentStore from "store/DocumentStore"; | ||
import { UPSERT_DOCUMENT_EMBED } from "graphql/mutation/DocumentMutation"; | ||
import { handleError } from "graphql/ApolloClient"; | ||
import Modal from "components/base/Modal"; | ||
|
||
const ShareAssignment = () => { | ||
const activeDocumentId = useDocumentStore((state) => state.activeDocumentId); | ||
const activeDocumentTitle = useDocumentStore( | ||
(state) => state.activeDocument?.title, | ||
); | ||
const { data, loading } = useQuery<GetDocumentEmbedSession>( | ||
GET_DOCUMENT_EMBED_SESSION, | ||
{ | ||
fetchPolicy: "network-only", | ||
variables: { | ||
documentId: activeDocumentId, | ||
}, | ||
skip: !activeDocumentId, | ||
}, | ||
); | ||
const [upsertEmbed, { loading: upsertLoading }] = | ||
useMutation<UpsertDocumentEmbedSession>(UPSERT_DOCUMENT_EMBED, { | ||
onError: handleError, | ||
}); | ||
const [isActive, setIsActive] = useState( | ||
data?.documentGetEmbeddedSession?.isActive || false, | ||
); | ||
const [sessionId, setSessionId] = useState( | ||
data?.documentGetEmbeddedSession?.sessionId, | ||
); | ||
|
||
useEffect(() => { | ||
if (data && !data.documentGetEmbeddedSession) { | ||
initialEmbedSession(); | ||
} | ||
|
||
if (data && data.documentGetEmbeddedSession) { | ||
setIsActive(data.documentGetEmbeddedSession.isActive); | ||
setSessionId(data.documentGetEmbeddedSession.sessionId); | ||
} | ||
}, [data]); | ||
|
||
const initialEmbedSession = async () => { | ||
const { data } = await upsertEmbed({ | ||
variables: { | ||
session: { | ||
documentId: activeDocumentId, | ||
isActive: false, | ||
embeddedType: EmbeddedType.FORM, | ||
}, | ||
}, | ||
}); | ||
|
||
if (data) { | ||
setIsActive(data.documentUpsertEmbeddedSession.isActive); | ||
setSessionId(data.documentUpsertEmbeddedSession.sessionId); | ||
} | ||
}; | ||
|
||
const onChangeActive = async (isActive: boolean) => { | ||
const { data } = await upsertEmbed({ | ||
variables: { | ||
session: { | ||
documentId: activeDocumentId, | ||
isActive, | ||
embeddedType: EmbeddedType.FORM, | ||
}, | ||
}, | ||
}); | ||
|
||
if (data) { | ||
setIsActive(data.documentUpsertEmbeddedSession.isActive); | ||
} | ||
}; | ||
|
||
const onCopyShareUrl = () => { | ||
const url = formatShareDocument(activeDocumentId, sessionId); | ||
copy(url); | ||
toast.success(t`Copied!`); | ||
}; | ||
|
||
if (loading) { | ||
return <Loading />; | ||
} | ||
|
||
const url = formatShareDocument(activeDocumentId, sessionId); | ||
return ( | ||
<div> | ||
<div> | ||
<Text weight="bold">Enable Share & Embed feature</Text> | ||
<div style={{ display: "flex", alignItems: "center", gap: 5 }}> | ||
<Text color={"gray"} size="2"> | ||
<Trans> | ||
Your students will need to fill out a form before they can start | ||
the assignment. The responses of students will be stored in the | ||
Share Responses tab. | ||
</Trans> | ||
</Text> | ||
</div> | ||
<div style={{ marginTop: 5 }}> | ||
<Switch | ||
checked={isActive} | ||
onCheckedChange={onChangeActive} | ||
disabled={upsertLoading} | ||
/> | ||
</div> | ||
</div> | ||
<Separator style={{ width: "100%", marginTop: 15, marginBottom: 15 }} /> | ||
<div style={{ display: "flex", gap: 15, height: 600 }}> | ||
<div | ||
style={{ | ||
display: "flex", | ||
flexDirection: "column", | ||
gap: 10, | ||
flex: 1, | ||
}} | ||
> | ||
<div> | ||
<Heading size="3"> | ||
<Trans>Share link</Trans>{" "} | ||
</Heading> | ||
</div> | ||
<div> | ||
<div style={{ display: "flex" }}> | ||
<TextField.Root value={url} style={{ flex: 1 }} size="2" readOnly> | ||
<TextField.Slot side="right"> | ||
<Button variant="soft" size="1" onClick={onCopyShareUrl}> | ||
<Link1Icon height="16" width="16" /> | ||
<Trans>Copy Link</Trans> | ||
</Button> | ||
</TextField.Slot> | ||
</TextField.Root> | ||
</div> | ||
<Text color={"gray"} size="2"> | ||
<Trans> | ||
Anyone with the link can <b>do the assignment</b> by filling out | ||
a form with their email, phone number, and name. | ||
</Trans> | ||
</Text> | ||
</div> | ||
<Separator style={{ width: "100%" }} /> | ||
<div style={{ flex: 1 }}> | ||
<Heading size="3"> | ||
<Trans>Embed code</Trans> | ||
</Heading> | ||
<Text color={"gray"} size="2"> | ||
<Trans> | ||
Copy the code below and paste it into your website to embed this | ||
assignment. | ||
</Trans> | ||
</Text> | ||
<TextArea | ||
value={`<iframe src="${url}" title="${activeDocumentTitle}" width="100%" height="100%" frameborder="0"></iframe>`} | ||
readOnly | ||
rows={6} | ||
/> | ||
</div> | ||
</div> | ||
<div style={{ flex: 1 }}> | ||
<Heading size="3"> | ||
<Trans>Form Preview</Trans> | ||
</Heading> | ||
<iframe | ||
src={`${url}?readOnly=true`} | ||
title="Ikigia Embedded" | ||
width="100%" | ||
height="100%" | ||
frameBorder={0} | ||
></iframe> | ||
</div> | ||
</div> | ||
</div> | ||
); | ||
}; | ||
|
||
export default ShareAssignment; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.