diff --git a/cypress/e2e/workspace.test.ts b/cypress/e2e/workspace.test.ts index 1209625..4f22545 100644 --- a/cypress/e2e/workspace.test.ts +++ b/cypress/e2e/workspace.test.ts @@ -8,7 +8,7 @@ context("Test the overall app", () => { describe("Desktop functionalities", () => { it("renders with text", () => { - ae.getApp().invoke("text").should("include", "To create or join a collaborative table"); + ae.getApp().invoke("text").should("include", "Begin sharing a table"); }); }); }); diff --git a/src/App.css b/src/App.css index a6569a3..bb2dd58 100755 --- a/src/App.css +++ b/src/App.css @@ -1,9 +1,30 @@ +:root { + --teal: #177991; + --dark-teal: #105262; + --dark-teal-2: #016082; + --teal-light-1: #93d5e4; + --teal-light-2: #b7e2ec; + --teal-light-3: #cdebf2; + --charcoal: #080808; + --charcoal-light: #333333; + --charcoal-light-5: #f2f2f2; +} + .App { - margin: 10px 4px; + color: var(--charcoal); + font-weight: 500; } .App div { - margin: 10px; + padding: 10px; + font-size: 12px; +} + +.App.sharing { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; } .App li { @@ -12,6 +33,7 @@ .App.sharing .callout { text-align: center; + font-style: italic; } .App.sharing .callout.shareId { @@ -23,4 +45,144 @@ .App .error-message { color: red; -} \ No newline at end of file +} + +.App .warning { + color: red; + font-style: italic; +} + +input { + height: 16px; + border: 1px solid var(--teal); + border-radius: 3px; +} + +input:focus { + border: 1px solid var(--teal); + background-color: var(--teal-light-3); + outline-width: 0px; +} + +.form-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + min-height: 200px; +} + +.separator { + width: 95%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +} + +div.separator-line { + border: .5px solid var(--charcoal); + width: 100%; + height: 0px; + padding: 0px; +} + +.button-stack { + gap: 15px; +} + +.input-row { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + align-self: flex-start; +} + +.input-row input { + width: 75px; +} + +.input-stack { + display: flex; + flex-direction: column; + align-self: flex-start; +} + +.input-stack input { + margin-left: 25px; +} + +.select-stack { + display: flex; + flex-direction: column; + align-self: flex-start; + width: 100%; +} + +.select-stack select { + align-self: flex-start; + margin-left: 25px; + border: 1px solid var(--teal); + padding: 2px; + border-radius: 3px; + cursor: pointer; + font-family: 'Montserrat', sans-serif; + font-size: 12px; +} + +.button-row { + display: flex; + flex-direction: row; + justify-content: center; + gap: 10px; + align-self: flex-end; +} + +button { + cursor: pointer; + background-color: var(--teal-light-3); + border-radius: 5px; + border-width: 1px; + color: var(--charcoal); + border-color: var(--charcoal); + padding: 5px 10px; + font-family: 'Montserrat', sans-serif; + font-size: 12px; + font-weight: 500; +} + +button:hover { + background-color: var(--teal-light-2); +} + +button:active { + background-color: var(--teal-light-1); +} + +button:disabled { + opacity: 0.7; + background-color: var(--charcoal-light-5); + color: var(--charcoal-light); + cursor: not-allowed; +} + +.cancel-button { + padding: 0px; + border: none; + border-radius: 5px; + color: var(--dark-teal-2); + background-color: #ffffff; + text-decoration: underline; + cursor: pointer; +} + +.cancel-button:hover { + background-color: #ffffff; + cursor: pointer; + color: var(--dark-teal) +} + +.leave-collaboration { + margin-top: 10px; +} diff --git a/src/App.tsx b/src/App.tsx index ff7a816..5b2d646 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,41 +1,22 @@ import pDebounce from "p-debounce"; import React, { Component, ChangeEvent } from "react"; import randomize from "randomatic"; -import pkg from "../package.json"; import "./App.css"; -import { CodapHelper as Codap, ISaveState } from "./lib/codap-helper"; +import { CodapHelper as Codap, } from "./lib/codap-helper"; import codapInterface from "./lib/CodapInterface"; import { DB } from "./lib/db"; import { DBSharedTable } from "./lib/db-types"; import { DataContext, CodapItem, CodapRequest } from "./lib/types"; - -const kPluginName = "Collaborative Data Sharing"; -const kVersion = pkg.version; -const kInitialDimensions = { - width: 350, - height: 400 -}; -const kSharedDimensions = { - width: 350, - height: 200 -}; - -const kShareIdLength = 6; - -const kNewSharedTable = "new-table"; -const kNewDataContextTitle = "Collaborative Table"; - -interface IState extends ISaveState { - id: string; - availableDataContexts: DataContext[]; - selectedDataContext: string; - personalDataLabel: string; - personalDataKey: string; - shareId?: string; - joinShareId: string; - isInProcessOfSharing: boolean; - showJoinShareError: boolean; -} +import { kInitialDimensions, kPluginName, kSharedDimensions, kVersion, kNewDataContextTitle, + kShareIdLength } from "./constants"; +import { IState } from "./types"; +import { FirstPage } from "./ui-pages/first-page"; +import { JoinAndMergeTable } from "./ui-pages/join-and-merge-table"; +import { ShareOptions } from "./ui-pages/share-options"; +import { ShareExistingTable } from "./ui-pages/share-existing-table"; +import { ShareNewTable } from "./ui-pages/share-new-table" +import { JoinOptions } from "./ui-pages/join-options"; +import { JoinWithoutMerging } from "./ui-pages/join-without-merging"; let database: DB; @@ -52,9 +33,10 @@ export default class App extends Component { lastSelectedDataContext: "", joinShareId: "", isInProcessOfSharing: false, - showJoinShareError: false + showJoinShareError: false, }; + public componentDidMount() { Codap.initializePlugin(kPluginName, kVersion, kInitialDimensions) .then(loadState => { @@ -90,55 +72,105 @@ export default class App extends Component { } } - renderForm() { - const { availableDataContexts, selectedDataContext, lastSelectedDataContext, - personalDataLabel, lastPersonalDataLabel, joinShareId, isInProcessOfSharing } = this.state; + renderFormPage() { + const { availableDataContexts, selectedDataContext, lastSelectedDataContext, shareTable, joinTable, + shareExistingTable, joinAndMergeTable, createNewTable, joinWithoutMerging } = this.state; + const showFirstStep = !shareTable && !joinTable; + const noSelectedSubOptions = !shareExistingTable && !createNewTable && !joinAndMergeTable && !joinWithoutMerging; + const showShareTableOptions = shareTable && !joinTable && noSelectedSubOptions; + const showJoinTableOptions = !shareTable && joinTable && noSelectedSubOptions; + + const setState = (state: Partial) => this.setState(state); + const handleJoinShareIdChange = (event: ChangeEvent) => this.handleJoinShareIdChange(event); + const handleDataLabelChange = (event: ChangeEvent) => this.handleDataLabelChange(event); + const joinShare = () => this.joinShare(); + const handleDataContextChange = (event: ChangeEvent) => this.handleDataContextChange(event); + const initiateShare = (selectedContext?: string) => { + if (selectedContext) { + this.setState({ selectedDataContext: selectedContext }); + } + this.initiateShare() + }; + const availableContextOptions = availableDataContexts.map((dc: DataContext) => - + ); - const selectedContextOption = selectedDataContext || lastSelectedDataContext || kNewSharedTable; - const readyToInitiateShare = (!!personalDataLabel || !!lastPersonalDataLabel) && !isInProcessOfSharing; - const readyToJoinShare = readyToInitiateShare && (joinShareId.length === kShareIdLength); + const selectedContextOption = selectedDataContext || lastSelectedDataContext || availableDataContexts[0]?.name; + + if (showFirstStep) { + return ( + + ) + } else if (showJoinTableOptions) { + return ( + + ) + } else if (showShareTableOptions) { + return ( + + ) + } else if (joinAndMergeTable) { + return ( + + ) + } else if (joinWithoutMerging) { + return ( + + ) + } else if (shareExistingTable) { + return ( + + ) + } else if (createNewTable) { + return ( + + ) + } + } + renderForm() { return (
- To create or join a collaborative table -
    -
  1. - Select a table to share or create a new one -
    - -
    -
  2. -
  3. - Provide a name or label for grouping -
    - -
    -
  4. -
  5. - Invite others to join your table or join another group -
    -
    - -
    -
    - or -
    -
    - Enter code to join another group: - - -
    -
    -
  6. -
+ {this.renderFormPage()} {this.renderErrorMessage()}
); @@ -159,19 +191,19 @@ export default class App extends Component { const tableName = dataContext ? dataContext.title : selectedDataContext; return (
-
- Table collaboration enabled for +
+ Table collaboration enabled for: +
+ {tableName} +
-
- {tableName} +
+ Others can join this table by using the code: +
+ {shareId} +
-
- Others can join this table by using the code -
-
- {shareId} -
-
+
@@ -263,7 +295,7 @@ export default class App extends Component { await Codap.removeItems(selectedDataContext, [items[0]]); items.shift(); } - + // write non-empty user items to firebase database.writeUserItems(personalDataKey, items.filter(item => !Codap.isEmptyUserItem(item))); return items; @@ -305,15 +337,15 @@ export default class App extends Component { initiateShare = async () => { await this.updatePersonalDataLabelAndKey(); - const {selectedDataContext, personalDataKey, personalDataLabel } = this.state; + const {selectedDataContext, personalDataKey, personalDataLabel, createNewTable, newTableName } = this.state; let dataContextName: string; this.setState({ isInProcessOfSharing: true }); try { - if (!selectedDataContext || (selectedDataContext === kNewSharedTable)) { + if (createNewTable) { // create new data context for sharing - const newContext = await Codap.createDataContext({title: kNewDataContextTitle}); + const newContext = await Codap.createDataContext({title: newTableName ?? kNewDataContextTitle}); if (newContext) { dataContextName = newContext.name; this.updateSelectedDataContext(dataContextName); @@ -351,7 +383,8 @@ export default class App extends Component { joinShare = async () => { await this.updatePersonalDataLabelAndKey(); - const {joinShareId: shareId, personalDataKey, personalDataLabel, selectedDataContext } = this.state; + const {joinShareId: shareId, personalDataKey, personalDataLabel, selectedDataContext, + joinAndMergeTable } = this.state; this.setState({ isInProcessOfSharing: true }); try { @@ -366,8 +399,9 @@ export default class App extends Component { let ownDataContextName; if (sharedContextData) { const { dataContext: sharedDataContext, itemData } = sharedContextData; - const existingDataContext = selectedDataContext && (selectedDataContext !== kNewSharedTable) && - await Codap.getDataContext(selectedDataContext); + + const existingDataContext = joinAndMergeTable && selectedDataContext && + await Codap.getDataContext(selectedDataContext); if (!existingDataContext) { const newDataContext = sharedDataContext && @@ -379,8 +413,7 @@ export default class App extends Component { } else { throw new Error("failed to create data context"); } - } - else { + } else { ownDataContextName = selectedDataContext; await Codap.addNewCollaborationCollections(selectedDataContext, personalDataKey, personalDataLabel, false); await Codap.syncDataContexts(selectedDataContext, sharedDataContext, true); @@ -397,8 +430,7 @@ export default class App extends Component { if (!itemData?.[personalDataKey]) { Codap.configureUserCase(ownDataContextName, personalDataKey, personalDataLabel, true); } - } - else { + } else { Codap.moveUserItemsToLast(selectedDataContext, personalDataKey); this.writeUserItems(selectedDataContext, personalDataKey); } @@ -423,7 +455,14 @@ export default class App extends Component { this.setState({ shareId: null, personalDataLabel: "", - joinShareId: "" + joinShareId: "", + shareTable: undefined, + joinTable: undefined, + shareExistingTable: undefined, + createNewTable: undefined, + joinAndMergeTable: undefined, + joinWithoutMerging: undefined, + newTableName: undefined }); database.leaveSharedTable(); }; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..ee02d1a --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,45 @@ + +import pkg from "../package.json"; + +export const kPluginName = "Collaborative Data Sharing"; +export const kVersion = pkg.version; +export const kInitialDimensions = { + width: 420, + height: 350 +}; + +export const kSharedDimensions = { + width: 400, + height: 350 +}; + +export const kShareIdLength = 6; + +export const kNewSharedTable = "new-table"; +export const kNewDataContextTitle = "Collaborative Table"; + + +export const OR = "or"; +export const BACK = "back"; + +export const BEGIN_SHARING_TABLE = "Begin sharing a table"; +export const JOIN_SOMEONE_ELSES_TABLE = "Join someone else's table"; + +export const SHARE_EXISTING_TABLE = "Share an existing table"; +export const CREATE_NEW_TABLE = "Create a new table to share"; + +export const SELECT_TABLE_TO_SHARE = "Select a table to share:"; +export const ENTER_NAME_FOR_TABLE = "Enter a name for the table:"; + +export const JOIN_AND_MERGE = "Join and merge an existing table"; +export const JOIN_WITHOUT_MERGING = "Join without merging an existing table"; + +export const SELECT_TABLE_TO_MERGE = "Select a table to merge:"; +export const ENTER_CODE_OF_GROUP = "Enter the code of the group to join:"; + +export const PROVIDE_NAME_OR_LABEL = "Provide a name or label for your data:"; + +export const BEGIN_COLLABORATION = "Begin Collaboration"; + +export const NO_TABLES_TO_MERGE = "You don't have any tables to merge. Create a new one or import a table to continue."; +export const NO_TABLES_TO_SHARE = "You don't have any tables to share. Create a new one or import a table to continue."; diff --git a/src/index.css b/src/index.css index 66bebec..04d6490 100755 --- a/src/index.css +++ b/src/index.css @@ -1,9 +1,7 @@ body { margin: 0; padding: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: 'Montserrat', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } \ No newline at end of file diff --git a/src/index.html b/src/index.html index fc62129..20601dc 100644 --- a/src/index.html +++ b/src/index.html @@ -5,6 +5,7 @@ Codap Shared Table Plugin +
diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..726fb20 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,21 @@ +import { ISaveState } from "./lib/codap-helper"; +import { DataContext } from "./lib/types"; + +export interface IState extends ISaveState { + id: string; + availableDataContexts: DataContext[]; + selectedDataContext: string; + personalDataLabel: string; + personalDataKey: string; + shareId?: string; + joinShareId: string; + isInProcessOfSharing: boolean; + showJoinShareError: boolean; + shareTable?: boolean; + joinTable?: boolean; + shareExistingTable?: boolean; + createNewTable?: boolean; + joinAndMergeTable?: boolean; + joinWithoutMerging?: boolean; + newTableName?: string; +} diff --git a/src/ui-pages/first-page.tsx b/src/ui-pages/first-page.tsx new file mode 100644 index 0000000..312881b --- /dev/null +++ b/src/ui-pages/first-page.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { IState } from '../types'; +import { BEGIN_SHARING_TABLE, JOIN_SOMEONE_ELSES_TABLE, OR } from '../constants'; + +interface FirstPageProps { + updateState: (state: Partial) => void; +} + +export const FirstPage = ({updateState}: FirstPageProps) => { + return ( +
+
+ +
+
+
+
{OR}
+
+
+
+ +
+
+ ) +}; diff --git a/src/ui-pages/join-and-merge-table.tsx b/src/ui-pages/join-and-merge-table.tsx new file mode 100644 index 0000000..b2035bd --- /dev/null +++ b/src/ui-pages/join-and-merge-table.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { BEGIN_COLLABORATION, BACK, PROVIDE_NAME_OR_LABEL, + ENTER_CODE_OF_GROUP, SELECT_TABLE_TO_MERGE, + NO_TABLES_TO_MERGE} from "../constants"; +import { IState } from "../types"; + +interface JoinAndMergeTableProps { + selectedContextOption: string; + availableContextOptions: JSX.Element[]; + joinShareId: string; + personalDataLabel: string; + lastPersonalDataLabel: string; + handleDataContextChange: (event: React.ChangeEvent) => void; + handleJoinShareIdChange: (event: React.ChangeEvent) => void; + handleDataLabelChange: (event: React.ChangeEvent) => void; + joinShare: () => void; + updateState: (state: Partial) => void; +} + +export const JoinAndMergeTable = (props: JoinAndMergeTableProps) => { + const { selectedContextOption, availableContextOptions, personalDataLabel, + lastPersonalDataLabel, handleDataContextChange, handleDataLabelChange, + joinShare, joinShareId, updateState, handleJoinShareIdChange } = props; + return ( +
+
+
{SELECT_TABLE_TO_MERGE}
+ {availableContextOptions.length > 0 + ? + :
+ {NO_TABLES_TO_MERGE} +
+ } +
+
+
{PROVIDE_NAME_OR_LABEL}
+ +
+
+
{ENTER_CODE_OF_GROUP}
+ +
+
+ + +
+
+ ) +}; diff --git a/src/ui-pages/join-options.tsx b/src/ui-pages/join-options.tsx new file mode 100644 index 0000000..edbe63d --- /dev/null +++ b/src/ui-pages/join-options.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { BACK, JOIN_AND_MERGE, OR, JOIN_WITHOUT_MERGING } from "../constants"; +import { IState } from "../types"; + +interface JoinOptionsProps { + updateState: (state: Partial) => void; +} + +export const JoinOptions = ({updateState}: JoinOptionsProps) => { + return ( +
+
+ +
+
+
+
{OR}
+
+
+
+ +
+
+ +
+
+ ) +}; diff --git a/src/ui-pages/join-without-merging.tsx b/src/ui-pages/join-without-merging.tsx new file mode 100644 index 0000000..514b06a --- /dev/null +++ b/src/ui-pages/join-without-merging.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { IState } from "../types"; +import { BACK, PROVIDE_NAME_OR_LABEL, BEGIN_COLLABORATION, ENTER_CODE_OF_GROUP } from "../constants"; + +interface JoinWithoutMergingProps { + joinShareId: string; + personalDataLabel: string; + lastPersonalDataLabel: string; + handleJoinShareIdChange: (event: React.ChangeEvent) => void; + handleDataLabelChange: (event: React.ChangeEvent) => void; + joinShare: () => void; + updateState: (state: Partial) => void; +} + +export const JoinWithoutMerging = (props: JoinWithoutMergingProps) => { + const { joinShareId, personalDataLabel, lastPersonalDataLabel, handleJoinShareIdChange, + handleDataLabelChange, joinShare, updateState } = props; + return ( +
+
+
{PROVIDE_NAME_OR_LABEL}
+ +
+
+
{ENTER_CODE_OF_GROUP}
+ +
+
+ + +
+
+ ) +}; diff --git a/src/ui-pages/share-existing-table.tsx b/src/ui-pages/share-existing-table.tsx new file mode 100644 index 0000000..7330c25 --- /dev/null +++ b/src/ui-pages/share-existing-table.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { BEGIN_COLLABORATION, BACK, PROVIDE_NAME_OR_LABEL, SELECT_TABLE_TO_SHARE, + NO_TABLES_TO_SHARE } from "../constants"; +import { IState } from "../types"; + +interface ShareExistingTableProps { + selectedContextOption: string; + availableContextOptions: JSX.Element[]; + personalDataLabel: string; + lastPersonalDataLabel: string; + handleDataContextChange: (event: React.ChangeEvent) => void; + handleDataLabelChange: (event: React.ChangeEvent) => void; + initiateShare: (selectedContextOption?: string) => void; + updateState: (state: Partial) => void; +} + +export const ShareExistingTable = (props: ShareExistingTableProps) => { + const { selectedContextOption, availableContextOptions, personalDataLabel, + lastPersonalDataLabel, handleDataContextChange, handleDataLabelChange, + initiateShare, updateState } = props; + return ( +
+
+
{SELECT_TABLE_TO_SHARE}
+ {availableContextOptions.length > 0 + ? + :
+ {NO_TABLES_TO_SHARE} +
+ } +
+
+
{PROVIDE_NAME_OR_LABEL}
+ +
+
+ + +
+
+ ) +}; diff --git a/src/ui-pages/share-new-table.tsx b/src/ui-pages/share-new-table.tsx new file mode 100644 index 0000000..82ccebb --- /dev/null +++ b/src/ui-pages/share-new-table.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { BEGIN_COLLABORATION, BACK, ENTER_NAME_FOR_TABLE, PROVIDE_NAME_OR_LABEL } from "../constants"; +import { IState } from "../types"; + +interface ShareNewTableProps { + newTableName: string; + updateState: (state: Partial) => void; + personalDataLabel: string; + lastPersonalDataLabel: string; + handleDataLabelChange: (event: React.ChangeEvent) => void; + initiateShare: (selectedContextOption?: string) => void; +} + +export const ShareNewTable = (props: ShareNewTableProps) => { + const { newTableName, updateState, personalDataLabel, lastPersonalDataLabel, + handleDataLabelChange, initiateShare} = props; + return ( +
+
+
{ENTER_NAME_FOR_TABLE}
+ updateState({ newTableName: e.target.value })} + /> +
+
+
{PROVIDE_NAME_OR_LABEL}
+ +
+
+ + +
+
+ ) +}; diff --git a/src/ui-pages/share-options.tsx b/src/ui-pages/share-options.tsx new file mode 100644 index 0000000..44f9fd7 --- /dev/null +++ b/src/ui-pages/share-options.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { BACK, CREATE_NEW_TABLE, SHARE_EXISTING_TABLE, OR } from "../constants"; +import { IState } from "../types"; + +interface ShareOptionsProps { + updateState: (state: Partial) => void; +} + +export const ShareOptions = ({updateState}: ShareOptionsProps) => { + return ( +
+
+ +
+
+
+
{OR}
+
+
+
+ +
+
+ +
+
+ ) +};