Include create-daml-app as a template project for daml new (#5259)

* Initial commit with create-daml-app master

* Include create-daml-app in build rule

* Make daml.yaml a template in version and project name

* Remove git attributes

* Remove license and azure config

* Remove scripts

* Don't overwrite config files in build rule

* Template version numbers in package.json, to be replaced by the assistant

* Rename to package.json.template

changelog_begin
changelog_end

* Add copyright headers

* Do template substitutions in all .template files

And don't special case daml new create-daml-app (so it treats it as a
regular template).

* Add create-daml-app to integration tests

* Remove WIP warning

* Move towards setup that works on head

* Make local copies of the TS libs in the templates tarball

* Hardcode project name for now

* Use isExtensionOf

* Remove service worker

* remove robots.txt (don't even know what it is)

* Revert "Make local copies of the TS libs in the templates tarball"

This reverts commit 1289581fb4a82af3ab534baf978a2c6ed895d538.

* Retemplatize TS lib versions. For head builds these will be installed using npm

* Remove daml/ledger from resolutions for daml-ts

* Comment about test secret

* Remove special create-daml-app assistant command and test that won't work anymore

* Remove redundant imports and export

* Remove old create-daml-app tests

* Remove yarn.lock

* Clean up integration test (just daml new and build atm)

* Add daml/ledger as a resolution for daml-ts

* Remove top level package.json

* Update daml.js version

* Use new import scheme for generated TS

* Update readme with new codegen and build steps

* Use start-navigator in daml.yaml

* Increase a couple of timeouts in tests (either sandbox or TS lib is a bit slower?)

* Update GSG intro with new build steps

* Remove daml2ts -p flag and --start-navigator flag from GSG instructions

* Don't use start-navigator flag in ui tests

* Temporary readme describing how to manually test the create-daml-app template

* Update code samples in app arch section of GSG

* Update code samples in testing doc

* Remove copied create-daml-app code

* Indent docs markers to be more subtle

* Update visible code in Messages (after) section

This needs to be kept up to date properly somehow.

* Update text to useLedger

* Restore code/ui-before copies until the Bazel magic is figured out

We need to make the template code a dependency in the Bazel rule as
otherwise we can't find the files in the docs build.

* Update create-daml-app/readme and make templates/readme more detailed

* Use jsx comments for docs markers so they don't show up in the app
This commit is contained in:
Rohan Jacob-Rao 2020-04-01 20:30:07 -04:00 committed by GitHub
parent 3945708192
commit 0f5d93e0c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1330 additions and 236 deletions

View File

@ -92,28 +92,3 @@ da_haskell_test(
"//libs-haskell/test-utils",
],
)
# Misc tests for daml-helper that do not deserve their own test suite.
da_haskell_test(
name = "tests",
srcs = ["test/DA/Daml/Helper/Test.hs"],
data = [
"daml-helper",
],
hackage_deps = [
"base",
"directory",
"extra",
"filepath",
"process",
"tasty",
"tasty-hunit",
],
main_function = "DA.Daml.Helper.Test.main",
visibility = ["//visibility:public"],
deps = [
"//libs-haskell/bazel-runfiles",
"//libs-haskell/da-hs-base",
"//libs-haskell/test-utils",
],
)

View File

@ -319,10 +319,8 @@ runCommand = \case
RunJar {..} ->
(if shutdownStdinClose then withCloseOnStdin else id) $
runJar jarPath mbLogbackConfig remainingArguments
New {..}
| templateNameM == Just "create-daml-app" -> runCreateDamlApp targetFolder
| otherwise -> runNew targetFolder templateNameM
CreateDamlApp{..} -> runCreateDamlApp targetFolder
New {..} -> runNew targetFolder templateNameM
CreateDamlApp{..} -> runNew targetFolder (Just "create-daml-app")
Init {..} -> runInit targetFolderM
ListTemplates -> runListTemplates
Start {..} ->

View File

@ -5,7 +5,6 @@ module DA.Daml.Helper.Run
( runDamlStudio
, runInit
, runNew
, runCreateDamlApp
, runJar
, runDaml2ts
, runListTemplates
@ -51,20 +50,14 @@ import Control.Concurrent
import Control.Concurrent.Async
import Control.Exception.Safe
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Extra hiding (fromMaybeM)
import Control.Monad.Loops (untilJust)
import Data.Conduit (runConduitRes, (.|))
import Data.Conduit.Combinators (sinkHandle)
import qualified Data.Conduit.Tar.Extra as Tar
import qualified Data.Conduit.Zlib as Zlib
import Data.Foldable
import qualified Data.HashMap.Strict as HashMap
import Data.Maybe
import qualified Data.Map.Strict as Map
import Data.List.Extra
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSChar8
import qualified Data.ByteString.Lazy.UTF8 as UTF8
import DA.PortFile
import qualified Data.Text as T
@ -545,67 +538,24 @@ runNew targetFolder templateNameM = do
files <- listFilesRecursive targetFolder
mapM_ setWritable files
-- Update daml.yaml
let configPath = targetFolder </> projectConfigName
configTemplatePath = configPath <.> "template"
whenM (doesFileExist configTemplatePath) $ do
configTemplate <- readFileUTF8 configTemplatePath
-- Substitute strings in template files (not a DAML template!)
-- e.g. the SDK version numbers in daml.yaml and package.json
let templateFiles = filter (".template" `isExtensionOf`) files
forM_ templateFiles $ \templateFile -> do
templateContent <- readFileUTF8 templateFile
sdkVersion <- getSdkVersion
let config = replace "__VERSION__" sdkVersion
. replace "__PROJECT_NAME__" projectName
$ configTemplate
writeFileUTF8 configPath config
removeFile configTemplatePath
let content = replace "__VERSION__" sdkVersion
. replace "__PROJECT_NAME__" projectName
$ templateContent
realFile = dropExtension templateFile
writeFileUTF8 realFile content
removeFile templateFile
-- Done.
putStrLn $
"Created a new project in \"" <> targetFolder <>
"\" based on the template \"" <> templateName <> "\"."
runCreateDamlApp :: FilePath -> IO ()
runCreateDamlApp targetFolder = do
whenM (doesDirectoryExist targetFolder) $ do
hPutStr stderr $ unlines
[ "Directory " <> show targetFolder <> " already exists."
, "Please specify a new directory or delete the directory."
]
exitFailure
sdkVersion <- getSdkVersion
request <- HTTP.parseRequest ("GET " <> url sdkVersion)
HTTP.withResponse request $ \response -> do
if | HTTP.getResponseStatus response == HTTP.notFound404 -> do
-- We treat 404s specially to provide a better error message.
hPutStrLn stderr $ unlines
[ "create-daml-app is not available for SDK version " <> sdkVersion <> "."
, "You need to use at least SDK version 1.0. If this is a new release,"
, "try again in a few hours."
]
exitFailure
| not (HTTP.statusIsSuccessful $ HTTP.getResponseStatus response) -> do
hPutStrLn stderr $ unlines
[ "Failed to download create-daml-app from " <> show (url sdkVersion) <> "."
, "Verify that your network is working and that you can"
, "access https://github.com/digital-asset/create-daml-app"
]
hPrint stderr (HTTP.getResponseStatus response)
runConduitRes (HTTP.getResponseBody response .| sinkHandle stderr )
-- trailing newline
BSChar8.hPutStrLn stderr ""
exitFailure
| otherwise -> do
-- Successful request so now extract it to the target folder.
let extractError msg e = liftIO $ fail $
"Failed to extract tarball: " <> T.unpack msg <> ": " <> T.unpack e
runConduitRes $
HTTP.getResponseBody response
.| Zlib.ungzip
.| Tar.untar (Tar.restoreFile extractError targetFolder)
putStrLn $ "Created a new DAML app in " <> show targetFolder <> "."
where
url version = "https://github.com/digital-asset/create-daml-app/archive/v" <> version <> ".tar.gz"
defaultProjectTemplate :: String
defaultProjectTemplate = "skeleton"

View File

@ -1,66 +0,0 @@
-- Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
-- SPDX-License-Identifier: Apache-2.0
module DA.Daml.Helper.Test (main) where
import Control.Monad
import DA.Bazel.Runfiles
import DA.Test.Util
import System.Directory
import System.Environment.Blank
import System.Exit
import System.FilePath
import System.Info
import System.IO.Extra
import System.Process
import Test.Tasty
import Test.Tasty.HUnit
main :: IO ()
main = do
setEnv "TASTY_NUM_THREADS" "1" True
when (os == "darwin") $ do
-- x509-system insists on trying to locate `security`
-- in PATH to find the root certificate store.
mbPath <- getEnv "PATH"
setEnv "PATH" (maybe "/usr/bin" ("/usr/bin:" <>) mbPath) True
damlHelper <- locateRunfiles (mainWorkspace </> "daml-assistant" </> "daml-helper" </> exe "daml-helper")
defaultMain $
testGroup "daml-helper"
[ createDamlAppTests damlHelper
]
createDamlAppTests :: FilePath -> TestTree
createDamlAppTests damlHelper = testGroup "create-daml-app"
[ testCase "Succeeds with SDK 0.13.55" $ withTempDir $ \dir -> do
env <- getEnvironment
(exit, out, err) <- readCreateProcessWithExitCode
(proc damlHelper ["create-daml-app", dir </> "foobar"])
{ env = Just (("DAML_SDK_VERSION", "0.13.55") : env) }
""
err @?= ""
assertInfixOf "Created" out
exit @?= ExitSuccess
assertBool "daml.yaml does not exist" =<<
doesFileExist (dir </> "foobar" </> "daml.yaml")
, testCase "Fails with SDK 0.0.1" $ withTempDir $ \dir -> do
-- Note that we do not test 0.0.0 since people
-- might be tempted to create that tag temporarily for
-- testing purposes.
env <- getEnvironment
(exit, out, err) <- readCreateProcessWithExitCode
(proc damlHelper ["create-daml-app", dir </> "foobar"])
{ env = Just (("DAML_SDK_VERSION", "0.0.1") : env) }
""
assertInfixOf "not available for SDK version 0.0.1" err
out @?= ""
exit @?= ExitFailure 1
, testCase "Fails if directory already exists" $ withTempDir $ \dir -> do
createDirectory (dir </> "foobar")
(exit, out, err) <- readCreateProcessWithExitCode
(proc damlHelper ["create-daml-app", dir </> "foobar"])
""
assertInfixOf "already exists" err
out @?= ""
exit @?= ExitFailure 1
]

View File

@ -329,7 +329,7 @@ quickstartTests :: FilePath -> FilePath -> TestTree
quickstartTests quickstartDir mvnDir = testGroup "quickstart"
[ testCase "daml new" $
callCommandQuiet $ unwords ["daml", "new", quickstartDir, "quickstart-java"]
, testCase "daml build " $ withCurrentDirectory quickstartDir $
, testCase "daml build" $ withCurrentDirectory quickstartDir $
callCommandQuiet "daml build"
, testCase "daml test" $ withCurrentDirectory quickstartDir $
callCommandQuiet "daml test"
@ -512,6 +512,7 @@ templateTests = testGroup "templates"
, "quickstart-scala"
, "script-example"
, "skeleton"
, "create-daml-app"
]
-- | Check we can generate language bindings.

View File

@ -61,8 +61,8 @@ The last part of the DAML model is the operation to follow users, called a *choi
.. literalinclude:: code/daml/User.daml
:language: daml
:start-after: -- ADDFRIEND_BEGIN
:end-before: -- ADDFRIEND_END
:start-after: -- FOLLOW_BEGIN
:end-before: -- FOLLOW_END
DAML contracts are *immutable* (can not be changed in place), so the only way to "update" one is to archive it and create a new instance.
That is what the ``Follow`` choice does: after checking some preconditions, it archives the current user contract and creates a new one with the new user to follow added to the list. Here is a quick explanation of the code:
@ -90,11 +90,10 @@ We do this using a DAML to TypeScript code generation tool in the DAML SDK.
To run code generation, we first need to compile the DAML model to an archive format (a ``.dar`` file).
The ``daml codegen ts`` command then takes this file as argument to produce a number of TypeScript packages in the output folder.
It also updates our ``package.json`` file with the newly generated dependencies.
::
daml build
daml codegen ts .daml/dist/create-daml-app-0.1.0.dar -o daml-ts -p package.json
daml codegen ts .daml/dist/create-daml-app-0.1.0.dar -o daml-ts
Now we have a TypeScript interface (types and companion objects) to our DAML model, which we'll use in our UI code next.
@ -136,7 +135,7 @@ It uses DAML React hooks to query and update ledger state.
The ``useParty`` hook simply returns the current user as stored in the ``DamlLedger`` context.
A more interesting example is the ``allUsers`` line.
This uses the ``useStreamQuery`` hook to get all ``User`` contracts on the ledger.
(``User`` here is an object generated by ``daml codegen ts`` - it stores metadata of the ``User`` template defined in ``User.daml``.)
(``User.User`` here is an object generated by ``daml codegen ts`` - it stores metadata of the ``User`` template defined in ``User.daml``.)
Note however that this query preserves privacy: only users that follow the current user have their contracts revealed.
This behaviour is due to the observers on the ``User`` contract being exactly in the list of users that the current user is following.
@ -150,7 +149,8 @@ Another example, showing how to *update* ledger state, is how we exercise the ``
:start-after: // FOLLOW_BEGIN
:end-before: // FOLLOW_END
The ``useExerciseByKey`` hook returns the ``exerciseFollow`` function.
The ``useLedger`` hook returns an object with methods for exercising choices.
The core of the ``follow`` function here is the call to ``ledger.exerciseByKey``.
The *key* in this case is the username of the current user, used to look up the corresponding ``User`` contract.
The wrapper function ``follow`` is then passed to the subcomponents of ``MainView``.
For example, ``follow`` is passed to the ``UserList`` component as an argument (a `prop <https://reactjs.org/docs/components-and-props.html>`_ in React terms).

View File

@ -1,7 +1,6 @@
-- Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
-- SPDX-License-Identifier: Apache-2.0
module User where
-- MAIN_TEMPLATE_BEGIN
@ -16,7 +15,7 @@ template User with
key username: Party
maintainer key
-- ADDFRIEND_BEGIN
-- FOLLOW_BEGIN
nonconsuming choice Follow: ContractId User with
userToFollow: Party
controller username
@ -25,9 +24,9 @@ template User with
assertMsg "You cannot follow the same user twice" (userToFollow `notElem` following)
archive self
create this with following = userToFollow :: following
-- ADDFRIEND_END
-- FOLLOW_END
-- SENDMESSAGE_BEGIN
-- SENDMESSAGE_BEGIN
nonconsuming choice SendMessage: ContractId Message with
sender: Party
content: Text
@ -35,7 +34,7 @@ template User with
do
assertMsg "Designated user must follow you back to send a message" (elem sender following)
create Message with sender, receiver = username, content
-- SENDMESSAGE_END
-- SENDMESSAGE_END
-- MESSAGE_BEGIN
template Message with

View File

@ -76,7 +76,7 @@ const MainView: React.FC = () => {
onFollow={follow}
/>
</Segment>
// MESSAGES_SEGMENT_BEGIN
// MESSAGES_SEGMENT_BEGIN
<Segment>
<Header as='h2'>
<Icon name='pencil square' />
@ -91,7 +91,7 @@ const MainView: React.FC = () => {
<Divider />
<MessageList />
</Segment>
// MESSAGES_SEGMENT_END
// MESSAGES_SEGMENT_END
</Grid.Column>
</Grid.Row>
</Grid>

View File

@ -5,8 +5,8 @@
import React from 'react'
import { Form, Button } from 'semantic-ui-react';
import { Party } from '@daml/types';
import { User } from '@daml-ts/create-daml-app-0.1.0/lib/User';
import { useParty, useExerciseByKey } from '@daml/react';
import { User } from '@daml.js/create-daml-app-0.1.0';
import { useParty, useLedger } from '@daml/react';
type Props = {
followers: Party[];
@ -20,7 +20,7 @@ const MessageEdit: React.FC<Props> = ({followers}) => {
const [receiver, setReceiver] = React.useState<string | undefined>();
const [content, setContent] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
const exerciseSendMessage = useExerciseByKey(User.SendMessage);
const ledger = useLedger();
const submitMessage = async (event: React.FormEvent) => {
try {
@ -29,7 +29,7 @@ const MessageEdit: React.FC<Props> = ({followers}) => {
return;
}
setIsSubmitting(true);
await exerciseSendMessage(receiver, {sender, content});
await ledger.exerciseByKey(User.User.SendMessage, receiver, {sender, content});
setContent("");
} catch (error) {
alert(`Error sending message:\n${JSON.stringify(error)}`);

View File

@ -4,14 +4,14 @@
// MESSAGELIST_BEGIN
import React from 'react'
import { List, ListItem } from 'semantic-ui-react';
import { Message } from '@daml-ts/create-daml-app-0.1.0/lib/User';
import { Message } from '@daml.js/create-daml-app-0.1.0';
import { useStreamQuery } from '@daml/react';
/**
* React component displaying the list of messages for the current user.
*/
const MessageList: React.FC = () => {
const messagesResult = useStreamQuery(Message);
const messagesResult = useStreamQuery(User.Message);
return (
<List relaxed>

View File

@ -5,7 +5,7 @@ import React, { useCallback } from 'react'
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'
import Credentials, { computeCredentials } from '../Credentials';
import Ledger from '@daml/ledger';
import { User } from '@daml-ts/create-daml-app-0.1.0/lib/User';
import { User } from '@daml.js/create-daml-app-0.1.0';
import { DeploymentMode, deploymentMode, ledgerId, httpBaseUrl, wsBaseUrl } from '../config';
import { useEffect } from 'react';
@ -22,10 +22,10 @@ const LoginScreen: React.FC<Props> = ({onLogin}) => {
const login = useCallback(async (credentials: Credentials) => {
try {
const ledger = new Ledger({token: credentials.token, httpBaseUrl, wsBaseUrl});
let userContract = await ledger.lookupByKey(User, credentials.party);
let userContract = await ledger.fetchByKey(User.User, credentials.party);
if (userContract === null) {
const user = {username: credentials.party, following: []};
userContract = await ledger.create(User, user);
userContract = await ledger.create(User.User, user);
}
onLogin(credentials);
} catch(error) {
@ -81,7 +81,7 @@ const LoginScreen: React.FC<Props> = ({onLogin}) => {
<Segment>
{deploymentMode !== DeploymentMode.PROD_DABL
? <>
// BEGIN_FORM
// FORM_BEGIN
<Form.Input
fluid
icon='user'
@ -98,7 +98,7 @@ const LoginScreen: React.FC<Props> = ({onLogin}) => {
onClick={handleLogin}>
Log in
</Button>
// END_FORM
// FORM_END
</>
: <Button primary fluid onClick={handleDablLogin}>
Log in with DABL

View File

@ -4,17 +4,17 @@
import React, { useMemo } from 'react';
import { Container, Grid, Header, Icon, Segment, Divider } from 'semantic-ui-react';
import { Party } from '@daml/types';
import { User } from '@daml-ts/create-daml-app-0.1.0/lib/User';
import { useParty, useExerciseByKey, useStreamFetchByKey, useStreamQuery } from '@daml/react';
import { User } from '@daml.js/create-daml-app-0.1.0';
import { useParty, useLedger, useStreamFetchByKey, useStreamQuery } from '@daml/react';
import UserList from './UserList';
import PartyListEdit from './PartyListEdit';
// USERS_BEGIN
const MainView: React.FC = () => {
const username = useParty();
const myUserResult = useStreamFetchByKey(User, () => username, [username]);
const myUserResult = useStreamFetchByKey(User.User, () => username, [username]);
const myUser = myUserResult.contract?.payload;
const allUsers = useStreamQuery(User).contracts;
const allUsers = useStreamQuery(User.User).contracts;
// USERS_END
// Sorted list of users that are following the current user
@ -25,19 +25,19 @@ const MainView: React.FC = () => {
.sort((x, y) => x.username.localeCompare(y.username)),
[allUsers, username]);
// FOLLOW_BEGIN
const exerciseFollow = useExerciseByKey(User.Follow);
// FOLLOW_BEGIN
const ledger = useLedger();
const follow = async (userToFollow: Party): Promise<boolean> => {
try {
await exerciseFollow(username, {userToFollow});
await ledger.exerciseByKey(User.User.Follow, username, {userToFollow});
return true;
} catch (error) {
alert("Unknown error:\n" + JSON.stringify(error));
return false;
}
}
// FOLLOW_END
// FOLLOW_END
return (
<Container>
@ -71,12 +71,12 @@ const MainView: React.FC = () => {
</Header.Content>
</Header>
<Divider />
// USERLIST_BEGIN
// USERLIST_BEGIN
<UserList
users={followers}
onFollow={follow}
/>
// USERLIST_END
// USERLIST_END
</Segment>
</Grid.Column>
</Grid.Row>

View File

@ -5,7 +5,7 @@ import { ChildProcess, spawn, SpawnOptions } from 'child_process';
import waitOn from 'wait-on';
import Ledger from '@daml/ledger';
import { User } from '@daml-ts/create-daml-app-0.1.0/lib/User';
import { User } from '@daml.js/create-daml-app-0.1.0';
import { computeCredentials } from './Credentials';
import puppeteer, { Browser, Page } from 'puppeteer';
@ -41,11 +41,11 @@ test('Party names are unique', async () => {
// Use a single sandbox, JSON API server and browser for all tests for speed.
// This means we need to use a different set of parties and a new browser page for each test.
beforeAll(async () => {
// Run `daml start --start-navigator=no` to start up the sandbox and json api server.
// Run `daml start` to start up the sandbox and json api server.
// Run it from the repository root, where the `daml.yaml` lives.
// The path should already include '.daml/bin' in the environment where this is run.
const startOpts: SpawnOptions = { cwd: '..', stdio: 'inherit' };
startProc = spawn('daml', ['start', '--start-navigator=no'], startOpts);
startProc = spawn('daml', ['start'], startOpts);
// Run `yarn start` in another shell.
// Disable automatically opening a browser using the env var described here:
@ -93,13 +93,13 @@ test('create and look up user using ledger library', async () => {
const partyName = getParty();
const {party, token} = computeCredentials(partyName);
const ledger = new Ledger({token});
const users0 = await ledger.query(User);
const users0 = await ledger.query(User.User);
expect(users0).toEqual([]);
const user: User = {username: party, following: []};
const userContract1 = await ledger.create(User, user);
const userContract2 = await ledger.lookupByKey(User, party);
const user = {username: party, following: []};
const userContract1 = await ledger.create(User.User, user);
const userContract2 = await ledger.fetchByKey(User.User, party);
expect(userContract1).toEqual(userContract2);
const users = await ledger.query(User);
const users = await ledger.query(User.User);
expect(users[0]).toEqual(userContract1);
});
@ -117,7 +117,7 @@ const newUiPage = async (): Promise<Page> => {
return page;
}
// BEGIN_LOGIN_FUNCTION
// LOGIN_FUNCTION_BEGIN
// Log in using a party name and wait for the main screen to load.
const login = async (page: Page, partyName: string) => {
const usernameInput = await page.waitForSelector('.test-select-username-field');
@ -126,7 +126,7 @@ const login = async (page: Page, partyName: string) => {
await page.click('.test-select-login-button');
await page.waitForSelector('.test-select-main-menu');
}
// END_LOGIN_FUNCTION
// LOGIN_FUNCTION_END
// Log out and wait to get back to the login screen.
const logout = async (page: Page) => {
@ -145,10 +145,10 @@ const follow = async (page: Page, userToFollow: string) => {
// We check this by the absence of the `loading` class.
// (Both the `test-...` and `loading` classes appear in `div`s surrounding
// the `input`, due to the translation of Semantic UI's `Input` element.)
await page.waitForSelector('.test-select-follow-input > :not(.loading)');
await page.waitForSelector('.test-select-follow-input > :not(.loading)', {timeout: 40_000});
}
// BEGIN_LOGIN_TEST
// LOGIN_TEST_BEGIN
test('log in as a new user, log out and log back in', async () => {
const partyName = getParty();
@ -159,7 +159,7 @@ test('log in as a new user, log out and log back in', async () => {
// Check that the ledger contains the new User contract.
const {token} = computeCredentials(partyName);
const ledger = new Ledger({token});
const users = await ledger.query(User);
const users = await ledger.query(User.User);
expect(users).toHaveLength(1);
expect(users[0].payload.username).toEqual(partyName);
@ -168,13 +168,13 @@ test('log in as a new user, log out and log back in', async () => {
await login(page, partyName);
// Check we have the same one user.
const usersFinal = await ledger.query(User);
const usersFinal = await ledger.query(User.User);
expect(usersFinal).toHaveLength(1);
expect(usersFinal[0].payload.username).toEqual(partyName);
await page.close();
}, 10_000);
// END_LOGIN_TEST
// LOGIN_TEST_END
// This tests following users in a few different ways:
// - using the text box in the Follow panel
@ -268,7 +268,7 @@ test('log in as three different users and start following each other', async ()
await page1.close();
await page2.close();
await page3.close();
}, 30_000);
}, 40_000);
test('error when following self', async () => {
const party = getParty();

View File

@ -21,7 +21,7 @@ There are three parts to building and running the messaging feature:
1. Adding the necessary changes to the DAML model
2. Making the corresponding changes in the UI
3. Running the new feature. In order to do that we need to terminate the previous ``daml start --start-navigator=no`` process and run it again.
3. Running the app with the new feature.
As usual, we must start with the DAML model and base our UI changes on top of that.
@ -74,14 +74,8 @@ Since we have changed our DAML code, we also need to rerun the TypeScript code g
Open a new terminal and run the following commands::
daml build
daml codegen ts .daml/dist/create-daml-app-0.1.0.dar -o daml-ts -p package.json
daml codegen ts .daml/dist/create-daml-app-0.1.0.dar -o daml-ts
Since we've generated new TypeScript packages in the ``daml-ts`` folder, we need to rebuild our project using these dependencies.
From the root ``create-daml-app`` folder, run::
yarn workspaces run build
This may take a couple of minutes.
The result is an up-to-date TypeScript interface to our DAML model, in particular to the new ``Message`` template and ``SendMessage`` choice.
We can now implement our messaging feature in the UI!
@ -142,8 +136,8 @@ The prop will be passed down from the ``MainView`` component, reusing the work r
You can see this ``following`` field bound at the start of the ``MessageEdit`` component.
We use the React ``useState`` hook to get and set the current choices of message ``receiver`` and ``content``.
The DAML-specific ``useExerciseByKey`` hook gives us a function to both look up a ``User`` contract and exercise the ``SendMessage`` choice on it.
The call to ``exerciseSendMessage`` in ``sendMessage`` looks up the ``User`` contract with the receiver's username and exercises ``SendMessage`` with the appropriate arguments.
The DAML-specific ``useLedger`` hook gives us an object we can use to perform ledger operations.
The call to ``ledger.exerciseByKey`` in ``sendMessage`` looks up the ``User`` contract with the receiver's username and exercises ``SendMessage`` with the appropriate arguments.
The ``sendMessage`` wrapper reports potential errors to the user, and ``submitMessage`` additionally uses the ``isSubmitting`` state to ensure message requests are processed one at a time.
The result of a successful call to ``submitMessage`` is a new ``Message`` contract created on the ledger.
@ -182,13 +176,15 @@ Let's give the new functionality a spin.
Running the New Feature
=======================
We need to terminate the previous ``daml start --start-navigator=no`` process and run it again, as we need to have a Sandbox instance with a DAR file containing the new feature. As a reminder, by running ``daml start --start-navigator=no`` again we will
We need to terminate the previous ``daml start`` process and run it again, as we need to have a Sandbox instance with a DAR file containing the new feature. As a reminder, by running ``daml start`` again we will
- Compile our DAML code into a *DAR file containing the new feature*
- Run a fresh instance of the *Sandbox with the new DAR file*
- Start the HTTP JSON API
First, navigate to the terminal window where the ``daml start --start-navigator=no`` process is running and terminate the active process by hitting ``Ctrl-C``. This shuts down the previous instances of the sandbox. Next in the root ``create-daml-app`` folder run ``daml start --start-navigator=no``.
First, navigate to the terminal window where the ``daml start`` process is running and terminate the active process by hitting ``Ctrl-C``.
This shuts down the previous instances of the sandbox.
Then in the root ``create-daml-app`` folder run ``daml start``.
As mentioned at the beginning of this *Getting Started with DAML* guide, DAML Sandbox uses an in-memory store, which means it loses its state when stopped or restarted. That means that all user data and follower relationships are lost.

View File

@ -51,9 +51,14 @@ Running the app
We'll start by getting the app up and running, and then explain the different components which we will later extend.
First off, open a terminal, clone the template project and move to the project folder::
First off, open a terminal and instantiate the template project.
::
daml new create-daml-app
This creates a new folder with contents from our template.
Change to the new folder::
git clone https://github.com/digital-asset/create-daml-app.git
cd create-daml-app
Next we need to compile the DAML code to a DAR file::
@ -65,12 +70,13 @@ Once the DAR file is created you will see this message in terminal ``Created .da
Any commands starting with ``daml`` are using the :doc:`DAML Assistant </tools/assistant>`, a command line tool in the DAML SDK for building and running DAML apps.
In order to connect the UI code to this DAML, we need to run a code generation step::
daml codegen ts .daml/dist/create-daml-app-0.1.0.dar -o daml-ts -p package.json
daml codegen ts .daml/dist/create-daml-app-0.1.0.dar -o daml-ts
Now, use Yarn to install the project dependencies and build the app::
Now, changing to the ``ui`` folder, use Yarn to install the project dependencies and build the app::
cd ui
yarn install
yarn workspaces run build
yarn build
These steps may take a couple of minutes each (it's worth it!).
You should see ``Compiled successfully.`` in the output if everything worked as expected.
@ -81,7 +87,7 @@ We can now run the app in two steps.
You'll need two terminal windows running for this.
In one terminal, at the root of the ``create-daml-app`` directory, run the command::
daml start --start-navigator=no
daml start
You will know that the command has started successfully when you see the ``INFO com.digitalasset.http.Main$ - Started server: ServerBinding(/127.0.0.1:7575)`` message in the terminal. The command does a few things:
@ -142,5 +148,3 @@ Just switch to the window where you are logged in as yourself - the network shou
Play around more with the app at your leisure: create new users and start following more users.
Observe when a user becomes visible to others - this will be important to understanding DAML's privacy model later.
When you're ready, let's move on to the :doc:`architecture of our app <app-architecture>`.
.. TODO: Add screenshots for the app above

View File

@ -51,8 +51,8 @@ Let's start at a higher level with a ``test``.
.. literalinclude:: code/ui-before/index.test.tsx
:language: tsx
:start-after: // BEGIN_LOGIN_TEST
:end-before: // END_LOGIN_TEST
:start-after: // LOGIN_TEST_BEGIN
:end-before: // LOGIN_TEST_END
We'll walk though this step by step.
@ -77,8 +77,8 @@ Let's see how ``login()`` is implemented.
.. literalinclude:: code/ui-before/index.test.tsx
:language: tsx
:start-after: // BEGIN_LOGIN_FUNCTION
:end-before: // END_LOGIN_FUNCTION
:start-after: // LOGIN_FUNCTION_BEGIN
:end-before: // LOGIN_FUNCTION_END
We first wait to receive a handle to the username input element.
This is important to ensure the page and relevant elements are loaded by the time we try to act on them.
@ -95,8 +95,8 @@ For example, here is a snippet of the ``LoginScreen`` React component with class
.. literalinclude:: code/ui-before/LoginScreen.tsx
:language: tsx
:start-after: // BEGIN_FORM
:end-before: // END_FORM
:start-after: // FORM_BEGIN
:end-before: // FORM_END
You can see the ``className`` attributes in the ``Input`` and ``Button``, which we select in the ``login()`` function.
Note that you can use other features of an element in your selector, such as its type and attributes.

View File

@ -7,6 +7,7 @@ genrule(
"default-gitignore",
"default-dlint-yaml",
"skeleton/**",
"create-daml-app/**",
"quickstart-java/**",
"quickstart-scala/**",
]) + [
@ -23,11 +24,12 @@ genrule(
OUT=templates-tarball
# templates in templates dir
for d in skeleton quickstart-scala quickstart-java; do
for d in skeleton create-daml-app quickstart-scala quickstart-java; do
mkdir -p $$OUT/$$d
cp -rL $$SRC/$$d/* $$OUT/$$d/
cp $$SRC/default-gitignore $$OUT/$$d/.gitignore
cp $$SRC/default-dlint-yaml $$OUT/$$d/.dlint.yaml
# use default .gitignore and .dlint.yaml if they don't exist in the template
cp -n $$SRC/default-gitignore $$OUT/$$d/.gitignore
cp -n $$SRC/default-dlint-yaml $$OUT/$$d/.dlint.yaml
done
## special cases we should work to remove

65
templates/README.txt Normal file
View File

@ -0,0 +1,65 @@
These are template projects for `daml new`.
Testing the create-daml-app template
====================================
While automated integration tests for the create-daml-app template are being built,
we have the following manual testing procedure.
Note that this is for testing against the head of the daml repo.
For testing against a released SDK, you can skip past the package.json step and use
`daml` instead of `daml-head` after installing the released version.
First, build the SDK from head using the `daml-sdk-head` command.
This gives an executable DAML assistant called `daml-head` in your path.
Next, instantiate the `create-daml-app` template as follows:
```
daml-head new create-daml-app
cd create-daml-app
```
Crucially, you'll need to add a package.json file at the root of the project for testing
(this is not required when using the released SDK).
It should look as follows, with the dummy paths here replaced by relative paths to locally
built TypeScript libraries.
(These need to be built from head using Bazel:
```
bazel build //language-support/ts/daml-types
bazel build //language-support/ts/daml-ledger
bazel build //language-support/ts/daml-react```)
package.json:
{
"resolutions": {
"@daml/types": "file:path/to/daml-types/npm_package",
"@daml/ledger": "file:path/to/daml-ledger/npm_package",
"@daml/react": "file:path/to/daml-react/npm_package"
},
"private": true,
"workspaces": [
"daml-ts",
"ui"
]
}
Now you can continue to build and run the project as described in create-daml-app/README.md,
using `daml-head` instead of `daml`.
Specifically, you should run the following in the root directory:
```
daml-head build
daml-head codegen ts .daml/dist/create-daml-app-0.1.0.dar -o daml-ts
daml-head start
```
Then in another terminal, navigate to `create-daml-app/ui/` and run:
```
yarn install
yarn start
```
And check that the app works.
Finally, terminate both the `daml start` and `yarn start` processes and run
`yarn test` from the `ui` directory. All tests should pass.

View File

@ -0,0 +1,4 @@
# Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
- ignore: {name: Use infix }

9
templates/create-daml-app/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
/.daml
build
node_modules
*.log
daml-ts/

View File

@ -0,0 +1,122 @@
[![DAML logo](https://daml.com/static/images/logo.png)](https://www.daml.com)
[![Download](https://img.shields.io/github/release/digital-asset/daml.svg?label=Download)](https://docs.daml.com/getting-started/installation.html)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/digital-asset/daml/blob/master/LICENSE)
# Welcome to _Create DAML App_
This repository contains a template to get started with developing full-stack
[DAML](https://daml.com/) applications. The demo application covers the following aspects:
1. A [DAML](https://docs.daml.com/index.html) model of a simple social network
2. A UI written in [TypeScript](https://www.typescriptlang.org/) and [React](https://reactjs.org/)
The UI is developed using [React](https://reactjs.org/),
[Semantic UI](https://react.semantic-ui.com/) and its
official [React integration](https://react.semantic-ui.com/).
The whole project was bootstrapped with
[Create React App](https://github.com/facebook/create-react-app).
Regardless of these choices, all DAML specific aspects of the UI client are
written in plain TypeScript and the UI framework should hence be easily
replaceable.
## Getting started
Before you can run the application, you need to install the
[yarn](https://yarnpkg.com/en/docs/install) package manager for JavaScript.
There are two steps to build the project.
First, we need to generate TypeScript code bindings for the compiled DAML model.
At the root of the repository, run
```
daml build
daml codegen ts .daml/dist/create-daml-app-0.1.0.dar -o daml-ts
```
The latter command generates TypeScript packages in the `daml-ts` directory.
Next, navigate to the `ui` directory and install the dependencies and build the app by running
```
cd ui
yarn install
yarn build
```
The last step is not absolutely necessary but useful to check that the app compiles.
To start the application, there are again two steps.
In one terminal in the root directory, start a DAML ledger using
```
daml start
```
This must continue running to serve ledger requests.
Then in a second terminal window in the `ui` directory, start the UI server via
```
yarn start
```
This should open a browser window with a login screen.
If it doesn't, you can manually point your browser to http://localhost:3000.
## A quick tour
You can log into the app by providing a user name, say `Alice`. For simplicity
of this app, there is no password or sign-up required. You will be greeted by
a screen indicating that you're not following anyone and that you don't have
any followers yet. You can change this by following someone in the upper box,
say `Bob`. After that, let's log out in the top right corner and log in as `Bob`.
As `Bob`, we can see that we are not following anyone and that `Alice` is follwing
us. We can follow `Alice` by clicking the plus symbol to the right of here name.
## Deploying to DABL
Deploying `create-daml-app` to the hosted DAML platform
[project:DABL](https://projectdabl.com/) is quite simple. Log into your DABL
account, create a new ledger and upload your DAML models and your UI.
To upload the DAML models, compile them into a DAR by executing
```
daml build -o create-daml-app.dar
```
at the root of your repository. Afterwards, open to the DABL website, select
the ledger you want to deploy to, go to the "DAML" selection and upload the
DAR `create-daml-app.dar` you have just created.
To upload the UI, create a ZIP file containing all your UI assets by executing
```
daml build
daml codegen ts .daml/dist/create-daml-app-0.1.0.dar -o daml-ts/src
yarn workspaces run build
(cd ui && zip -r ../create-daml-app-ui.zip build)
```
at the root of the repository. Afterwards, select the "UI Assets" tab of your
chosen ledger on the DABL website, upload the ZIP file
(`create-daml-app-ui.zip`) you have just created and publish it.
To see your deployed instance of `create-daml-app` in action, follow the
"Visit site" link at the top right corner of your "UI Assets" page.
## Next steps
There are many directions in which this application can be extended.
Regardless of which direction you pick, the following files will be the most
interesting ones to familiarize yourself with:
- [`daml/User.daml`](daml/User.daml): the DAML model of the social network
- [`daml-ts/src/create-daml-app-0.1.0/User.ts`](src/daml/User.ts) (once you've generated it):
a reflection of the types contained in the DAML model in TypeScript
- [`ui/src/components/MainView.tsx`](ui/src/components/MainView.tsx):
a React component using the HTTP Ledger API and rendering the main features
## Useful resources
TBD
## How to get help
TBD

View File

@ -0,0 +1,16 @@
sdk-version: __VERSION__
name: create-daml-app
version: 0.1.0
source: daml
parties:
- Alice
- Bob
- Charlie
dependencies:
- daml-prim
- daml-stdlib
- daml-trigger
sandbox-options:
- --wall-clock-time
- --ledgerid=create-daml-app-sandbox
start-navigator: false

View File

@ -0,0 +1,27 @@
-- Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
-- SPDX-License-Identifier: Apache-2.0
module User where
-- MAIN_TEMPLATE_BEGIN
template User with
username: Party
following: [Party]
where
signatory username
observer following
-- MAIN_TEMPLATE_END
key username: Party
maintainer key
-- FOLLOW_BEGIN
nonconsuming choice Follow: ContractId User with
userToFollow: Party
controller username
do
assertMsg "You cannot follow yourself" (userToFollow /= username)
assertMsg "You cannot follow the same user twice" (userToFollow `notElem` following)
archive self
create this with following = userToFollow :: following
-- FOLLOW_END

View File

@ -0,0 +1,53 @@
{
"name": "create-daml-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"@daml.js/create-daml-app-0.1.0": "file:../daml-ts/create-daml-app-0.1.0",
"@daml/types": "__VERSION__",
"@daml/ledger": "__VERSION__",
"@daml/react": "__VERSION__",
"@types/puppeteer": "^2.0.1",
"jwt-simple": "^0.5.6",
"puppeteer": "^2.1.1",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-scripts": "^3.3.0",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^0.88.1",
"typescript": "~3.7.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --testURL='http://localhost:7575'",
"eject": "react-scripts eject",
"lint": "eslint --ext .js,.jsx,.ts,.tsx src/"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:7575",
"devDependencies": {
"@types/jest": "24.0.18",
"@types/jwt-simple": "^0.5.33",
"@types/node": "12.7.12",
"@types/react": "^16.9.16",
"@types/react-dom": "^16.9.4",
"@types/wait-on": "^4.0.0",
"wait-on": "^4.0.1",
"eslint-config-react-app": "^5.2.0"
}
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg width="840px" height="226px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 840.2 226" style="enable-background:new 0 0 840.2 226;" xml:space="preserve">
<style type="text/css">
.st0{fill:#21356A;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#517CD9;}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);}
.st3{fill-rule:evenodd;clip-rule:evenodd;fill:#21356A;}
.st4{fill-rule:evenodd;clip-rule:evenodd;fill:#81A9FF;}
</style>
<path class="st0" d="M687.3,0h-59.2l-59.5,157.2L508.4,0h-60.2v226H499V85.9L549.2,226h38.6l48.6-140.1V226h50.9V0z"/>
<path class="st0" d="M776.1,180.8V0H724v226h116.2v-45.2H776.1z"/>
<path class="st0" d="M0,0v226h82.7c18.7,0,35.3-3.2,49.9-9.5c14.6-6.3,26.9-14.8,36.9-25.3c10-10.5,17.5-22.6,22.7-36.2
c5.1-13.6,7.7-27.5,7.7-42c0-14.4-2.6-28.4-7.9-42c-5.3-13.6-12.9-25.6-22.9-36.2c-10-10.5-22.2-19-36.7-25.3
C118,3.2,101.4,0,82.7,0H0z M80.1,45.2c10.5,0,19.9,1.9,28.2,5.6c8.3,3.8,15.2,8.8,20.9,15.2c5.7,6.4,10,13.6,13,21.8
c3,8.2,4.5,16.6,4.5,25.2c0,8.8-1.5,17.3-4.5,25.3c-3,8.1-7.4,15.3-13,21.6c-5.7,6.4-12.7,11.4-20.9,15.2
c-8.3,3.8-17.7,5.6-28.2,5.6h-28V45.2H80.1z"/>
<g>
<path class="st1" d="M285.4,2.1h50.3L406.1,226h-50.3L285.4,2.1z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="431.8985" y1="200.2006" x2="259.5836" y2="27.8857">
<stop offset="0" style="stop-color:#517CD9"/>
<stop offset="1" style="stop-color:#2C58B9"/>
</linearGradient>
<path class="st2" d="M285.4,2.1h50.3L406.1,226h-50.3L285.4,2.1z"/>
</g>
<polygon class="st3" points="290.4,146 265.3,226 215,226 240.1,146 "/>
<polygon class="st4" points="335.7,2.1 309.9,84.1 259.6,84.1 285.4,2.1 "/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<!-- Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-daml-app"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Create DAML App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.png",
"sizes": "32x32",
"type": "image/png"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,36 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import { encode } from 'jwt-simple';
import { ledgerId } from './config';
export const APPLICATION_ID: string = 'create-daml-app';
// NOTE: This is for testing purposes only.
// To handle authentication properly,
// see https://docs.daml.com/app-dev/authentication.html.
export const SECRET_KEY: string = 'secret';
export type Credentials = {
party: string;
token: string;
ledgerId: string;
}
function computeToken(party: string): string {
const payload = {
"https://daml.com/ledger-api": {
"ledgerId": ledgerId,
"applicationId": APPLICATION_ID,
"actAs": [party]
}
};
return encode(payload, SECRET_KEY, 'HS256');
}
export const computeCredentials = (party: string): Credentials => {
const token = computeToken(party);
return {party, token, ledgerId};
}
export default Credentials;

View File

@ -0,0 +1,12 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

View File

@ -0,0 +1,31 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import LoginScreen from './LoginScreen';
import MainScreen from './MainScreen';
import DamlLedger from '@daml/react';
import Credentials from '../Credentials';
import { httpBaseUrl, wsBaseUrl } from '../config';
/**
* React component for the entry point into the application.
*/
// APP_BEGIN
const App: React.FC = () => {
const [credentials, setCredentials] = React.useState<Credentials | undefined>();
return credentials
? <DamlLedger
token={credentials.token}
party={credentials.party}
httpBaseUrl={httpBaseUrl}
wsBaseUrl={wsBaseUrl}
>
<MainScreen onLogout={() => setCredentials(undefined)}/>
</DamlLedger>
: <LoginScreen onLogin={setCredentials} />
}
// APP_END
export default App;

View File

@ -0,0 +1,114 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react'
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'
import Credentials, { computeCredentials } from '../Credentials';
import Ledger from '@daml/ledger';
import { User } from '@daml.js/create-daml-app-0.1.0';
import { DeploymentMode, deploymentMode, ledgerId, httpBaseUrl, wsBaseUrl } from '../config';
import { useEffect } from 'react';
type Props = {
onLogin: (credentials: Credentials) => void;
}
/**
* React component for the login screen of the `App`.
*/
const LoginScreen: React.FC<Props> = ({onLogin}) => {
const [username, setUsername] = React.useState('');
const login = useCallback(async (credentials: Credentials) => {
try {
const ledger = new Ledger({token: credentials.token, httpBaseUrl, wsBaseUrl});
let userContract = await ledger.fetchByKey(User.User, credentials.party);
if (userContract === null) {
const user = {username: credentials.party, following: []};
userContract = await ledger.create(User.User, user);
}
onLogin(credentials);
} catch(error) {
alert(`Unknown error:\n${JSON.stringify(error)}`);
}
}, [onLogin]);
const handleLogin = async (event: React.FormEvent) => {
event.preventDefault();
const credentials = computeCredentials(username);
await login(credentials);
}
const handleDablLogin = () => {
window.location.assign(`https://login.projectdabl.com/auth/login?ledgerId=${ledgerId}`);
}
useEffect(() => {
const url = new URL(window.location.toString());
const token = url.searchParams.get('token');
if (token === null) {
return;
}
const party = url.searchParams.get('party');
if (party === null) {
throw Error("When 'token' is passed via URL, 'party' must be passed too.");
}
url.search = '';
window.history.replaceState(window.history.state, '', url.toString());
login({token, party, ledgerId});
}, [login]);
return (
<Grid textAlign='center' style={{ height: '100vh' }} verticalAlign='middle'>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h1' textAlign='center' size='huge' style={{color: '#223668'}}>
<Header.Content>
Create
<Image
as='a'
href='https://www.daml.com/'
target='_blank'
src='/daml.svg'
alt='DAML Logo'
spaced
size='small'
verticalAlign='middle'
/>
App
</Header.Content>
</Header>
<Form size='large' className='test-select-login-screen'>
<Segment>
{deploymentMode !== DeploymentMode.PROD_DABL
? <>
{/* FORM_BEGIN */}
<Form.Input
fluid
icon='user'
iconPosition='left'
placeholder='Username'
value={username}
className='test-select-username-field'
onChange={e => setUsername(e.currentTarget.value)}
/>
<Button
primary
fluid
className='test-select-login-button'
onClick={handleLogin}>
Log in
</Button>
{/* FORM_END */}
</>
: <Button primary fluid onClick={handleDablLogin}>
Log in with DABL
</Button>
}
</Segment>
</Form>
</Grid.Column>
</Grid>
);
};
export default LoginScreen;

View File

@ -0,0 +1,49 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react'
import { Image, Menu } from 'semantic-ui-react'
import MainView from './MainView';
import { useParty } from '@daml/react';
type Props = {
onLogout: () => void;
}
/**
* React component for the main screen of the `App`.
*/
const MainScreen: React.FC<Props> = ({onLogout}) => {
return (
<>
<Menu icon borderless>
<Menu.Item>
<Image
as='a'
href='https://www.daml.com/'
target='_blank'
src='/daml.svg'
alt='DAML Logo'
size='mini'
/>
</Menu.Item>
<Menu.Menu position='right' className='test-select-main-menu'>
<Menu.Item position='right'>
You are logged in as {useParty()}.
</Menu.Item>
<Menu.Item
position='right'
active={false}
className='test-select-log-out'
onClick={onLogout}
icon='log out'
/>
</Menu.Menu>
</Menu>
<MainView/>
</>
);
};
export default MainScreen;

View File

@ -0,0 +1,88 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useMemo } from 'react';
import { Container, Grid, Header, Icon, Segment, Divider } from 'semantic-ui-react';
import { Party } from '@daml/types';
import { User } from '@daml.js/create-daml-app-0.1.0';
import { useParty, useLedger, useStreamFetchByKey, useStreamQuery } from '@daml/react';
import UserList from './UserList';
import PartyListEdit from './PartyListEdit';
// USERS_BEGIN
const MainView: React.FC = () => {
const username = useParty();
const myUserResult = useStreamFetchByKey(User.User, () => username, [username]);
const myUser = myUserResult.contract?.payload;
const allUsers = useStreamQuery(User.User).contracts;
// USERS_END
// Sorted list of users that are following the current user
const followers = useMemo(() =>
allUsers
.map(user => user.payload)
.filter(user => user.username !== username)
.sort((x, y) => x.username.localeCompare(y.username)),
[allUsers, username]);
// FOLLOW_BEGIN
const ledger = useLedger();
const follow = async (userToFollow: Party): Promise<boolean> => {
try {
await ledger.exerciseByKey(User.User.Follow, username, {userToFollow});
return true;
} catch (error) {
alert("Unknown error:\n" + JSON.stringify(error));
return false;
}
}
// FOLLOW_END
return (
<Container>
<Grid centered columns={2}>
<Grid.Row stretched>
<Grid.Column>
<Header as='h1' size='huge' color='blue' textAlign='center' style={{padding: '1ex 0em 0ex 0em'}}>
{myUser ? `Welcome, ${myUser.username}!` : 'Loading...'}
</Header>
<Segment>
<Header as='h2'>
<Icon name='user' />
<Header.Content>
{myUser?.username ?? 'Loading...'}
<Header.Subheader>Users I'm following</Header.Subheader>
</Header.Content>
</Header>
<Divider />
<PartyListEdit
parties={myUser?.following ?? []}
onAddParty={follow}
/>
</Segment>
<Segment>
<Header as='h2'>
<Icon name='globe' />
<Header.Content>
The Network
<Header.Subheader>My followers and users they are following</Header.Subheader>
</Header.Content>
</Header>
<Divider />
{/* USERLIST_BEGIN */}
<UserList
users={followers}
onFollow={follow}
/>
{/* USERLIST_END */}
</Segment>
</Grid.Column>
</Grid.Row>
</Grid>
</Container>
);
}
export default MainView;

View File

@ -0,0 +1,67 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react'
import { Form, List, Button } from 'semantic-ui-react';
import { Party } from '@daml/types';
type Props = {
parties: Party[];
onAddParty: (party: Party) => Promise<boolean>;
}
/**
* React component to edit a list of `Party`s.
*/
const PartyListEdit: React.FC<Props> = ({parties, onAddParty}) => {
const [newParty, setNewParty] = React.useState('');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const addParty = async (event?: React.FormEvent) => {
if (event) {
event.preventDefault();
}
setIsSubmitting(true);
const success = await onAddParty(newParty);
setIsSubmitting(false);
if (success) {
setNewParty('');
}
}
return (
<List relaxed>
{[...parties].sort((x, y) => x.localeCompare(y)).map((party) =>
<List.Item
key={party}
>
<List.Icon name='user outline' />
<List.Content>
<List.Header className='test-select-following'>
{party}
</List.Header>
</List.Content>
</List.Item>
)}
<br />
<Form onSubmit={addParty}>
<Form.Input
fluid
readOnly={isSubmitting}
loading={isSubmitting}
className='test-select-follow-input'
placeholder="Username to follow"
value={newParty}
onChange={(event) => setNewParty(event.currentTarget.value)}
/>
<Button
type='submit'
className='test-select-follow-button'>
Follow
</Button>
</Form>
</List>
);
};
export default PartyListEdit;

View File

@ -0,0 +1,57 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react'
import { Icon, List } from 'semantic-ui-react'
import { Party } from '@daml/types';
import { User } from '@daml.js/create-daml-app-0.1.0';
type Props = {
users: User.User[];
onFollow: (userToFollow: Party) => void;
}
/**
* React component to display a list of `User`s.
* Every party in the list can be added as a friend.
*/
const UserList: React.FC<Props> = ({users, onFollow}) => {
return (
<List divided relaxed>
{[...users].sort((x, y) => x.username.localeCompare(y.username)).map(user =>
<List.Item key={user.username}>
<List.Icon name='user' />
<List.Content>
<List.Content floated='right'>
<Icon
name='add user'
link
className='test-select-add-user-icon'
onClick={() => onFollow(user.username)} />
</List.Content>
<List.Header className='test-select-user-in-network'>{user.username}</List.Header>
</List.Content>
<List.List>
{[...user.following].sort((x, y) => x.localeCompare(y)).map(userToFollow =>
<List.Item key={userToFollow}>
<List.Content floated='right'>
<Icon
name='add user'
link
className='test-select-add-user-following-icon'
onClick={() => onFollow(userToFollow)} />
</List.Content>
<List.Icon name='user outline' />
<List.Content>
<List.Header>{userToFollow}</List.Header>
</List.Content>
</List.Item>
)}
</List.List>
</List.Item>
)}
</List>
);
};
export default UserList;

View File

@ -0,0 +1,33 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
export enum DeploymentMode {
DEV,
PROD_DABL,
PROD_OTHER,
}
export const deploymentMode: DeploymentMode =
process.env.NODE_ENV === 'development'
? DeploymentMode.DEV
: window.location.hostname.endsWith('.projectdabl.com')
? DeploymentMode.PROD_DABL
: DeploymentMode.PROD_OTHER;
export const ledgerId =
deploymentMode === DeploymentMode.PROD_DABL
? window.location.hostname.split('.')[0]
: 'create-daml-app-sandbox';
export const httpBaseUrl =
deploymentMode === DeploymentMode.PROD_DABL
? `https://api.projectdabl.com/data/${ledgerId}/`
: undefined;
// Unfortunately, the development server of `create-react-app` does not proxy
// websockets properly. Thus, we need to bypass it and talk to the JSON API
// directly in development mode.
export const wsBaseUrl =
deploymentMode === DeploymentMode.DEV
? 'ws://localhost:7575/'
: undefined;

View File

@ -0,0 +1,16 @@
/* Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. */
/* SPDX-License-Identifier: Apache-2.0 */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -0,0 +1,305 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import { ChildProcess, spawn, SpawnOptions } from 'child_process';
import waitOn from 'wait-on';
import Ledger from '@daml/ledger';
import { User } from '@daml.js/create-daml-app-0.1.0';
import { computeCredentials } from './Credentials';
import puppeteer, { Browser, Page } from 'puppeteer';
const SANDBOX_PORT = 6865;
const JSON_API_PORT = 7575;
const UI_PORT = 3000;
// `daml start` process (which spawns a sandbox and JSON API server)
let startProc: ChildProcess | undefined = undefined;
// Headless Chrome browser:
// https://developers.google.com/web/updates/2017/04/headless-chrome
let browser: Browser | undefined = undefined;
let uiProc: ChildProcess | undefined = undefined;
// Function to generate unique party names for us.
// This should be replaced by the party management service once that is exposed
// in the HTTP JSON API.
let nextPartyId = 1;
function getParty(): string {
const party = `P${nextPartyId}`;
nextPartyId++;
return party;
}
test('Party names are unique', async () => {
const parties = new Set(Array(10).fill({}).map(() => getParty()));
expect(parties.size).toEqual(10);
});
// Use a single sandbox, JSON API server and browser for all tests for speed.
// This means we need to use a different set of parties and a new browser page for each test.
beforeAll(async () => {
// Run `daml start` to start up the sandbox and json api server.
// Run it from the repository root, where the `daml.yaml` lives.
// The path should already include '.daml/bin' in the environment where this is run.
const startOpts: SpawnOptions = { cwd: '..', stdio: 'inherit' };
startProc = spawn('daml', ['start'], startOpts);
// Run `yarn start` in another shell.
// Disable automatically opening a browser using the env var described here:
// https://github.com/facebook/create-react-app/issues/873#issuecomment-266318338
const env = {...process.env, BROWSER: 'none'};
uiProc = spawn('yarn', ['start'], { env, stdio: 'inherit', detached: true});
// Note(kill-yarn-start): The `detached` flag starts the process in a new process group.
// This allows us to kill the process with all its descendents after the tests finish,
// following https://azimi.me/2014/12/31/kill-child_process-node-js.html.
// We know the `daml start` and `yarn start` servers are ready once the relevant ports become available.
await waitOn({resources: [
`tcp:localhost:${SANDBOX_PORT}`,
`tcp:localhost:${JSON_API_PORT}`,
`tcp:localhost:${UI_PORT}`
]});
// Launch a browser once for all tests.
browser = await puppeteer.launch();
}, 40_000);
afterAll(async () => {
// Kill the `daml start` process.
// Note that `kill()` sends the `SIGTERM` signal but the actual processes may
// not die immediately.
// TODO: Test this on Windows.
if (startProc) {
startProc.kill();
}
// Kill the `yarn start` process including all its descendents.
// The `-` indicates to kill all processes in the process group.
// See Note(kill-yarn-start).
// TODO: Test this on Windows.
if (uiProc) {
process.kill(-uiProc.pid)
}
if (browser) {
browser.close();
}
});
test('create and look up user using ledger library', async () => {
const partyName = getParty();
const {party, token} = computeCredentials(partyName);
const ledger = new Ledger({token});
const users0 = await ledger.query(User.User);
expect(users0).toEqual([]);
const user = {username: party, following: []};
const userContract1 = await ledger.create(User.User, user);
const userContract2 = await ledger.fetchByKey(User.User, party);
expect(userContract1).toEqual(userContract2);
const users = await ledger.query(User.User);
expect(users[0]).toEqual(userContract1);
});
// The tests following use the headless browser to interact with the app.
// We select the relevant DOM elements using CSS class names that we embedded
// specifically for testing.
// See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors.
const newUiPage = async (): Promise<Page> => {
if (!browser) {
throw Error('Puppeteer browser has not been launched');
}
const page = await browser.newPage();
await page.goto(`http://localhost:${UI_PORT}`); // ignore the Response
return page;
}
// LOGIN_FUNCTION_BEGIN
// Log in using a party name and wait for the main screen to load.
const login = async (page: Page, partyName: string) => {
const usernameInput = await page.waitForSelector('.test-select-username-field');
await usernameInput.click();
await usernameInput.type(partyName);
await page.click('.test-select-login-button');
await page.waitForSelector('.test-select-main-menu');
}
// LOGIN_FUNCTION_END
// Log out and wait to get back to the login screen.
const logout = async (page: Page) => {
await page.click('.test-select-log-out');
await page.waitForSelector('.test-select-login-screen');
}
// Follow a user using the text input in the follow panel.
const follow = async (page: Page, userToFollow: string) => {
await page.click('.test-select-follow-input');
await page.type('.test-select-follow-input', userToFollow);
await page.click('.test-select-follow-button');
// Wait for the request to complete, either successfully or after the error
// dialog has been handled.
// We check this by the absence of the `loading` class.
// (Both the `test-...` and `loading` classes appear in `div`s surrounding
// the `input`, due to the translation of Semantic UI's `Input` element.)
await page.waitForSelector('.test-select-follow-input > :not(.loading)', {timeout: 40_000});
}
// LOGIN_TEST_BEGIN
test('log in as a new user, log out and log back in', async () => {
const partyName = getParty();
// Log in as a new user.
const page = await newUiPage();
await login(page, partyName);
// Check that the ledger contains the new User contract.
const {token} = computeCredentials(partyName);
const ledger = new Ledger({token});
const users = await ledger.query(User.User);
expect(users).toHaveLength(1);
expect(users[0].payload.username).toEqual(partyName);
// Log out and in again as the same user.
await logout(page);
await login(page, partyName);
// Check we have the same one user.
const usersFinal = await ledger.query(User.User);
expect(usersFinal).toHaveLength(1);
expect(usersFinal[0].payload.username).toEqual(partyName);
await page.close();
}, 10_000);
// LOGIN_TEST_END
// This tests following users in a few different ways:
// - using the text box in the Follow panel
// - using the icon in the Network panel
// - while the user that is followed is logged in
// - while the user that is followed is logged out
// These are all successful cases.
test('log in as three different users and start following each other', async () => {
const party1 = getParty();
const party2 = getParty();
const party3 = getParty();
// Log in as Party 1.
const page1 = await newUiPage();
await login(page1, party1);
// Party 1 should initially follow no one.
const noFollowing1 = await page1.$$('.test-select-following');
expect(noFollowing1).toEqual([]);
// Follow Party 2 using the text input.
// This should work even though Party 2 has not logged in yet.
// Check Party 1 follows exactly Party 2.
await follow(page1, party2);
await page1.waitForSelector('.test-select-following');
const followingList1 = await page1.$$eval('.test-select-following', following => following.map(e => e.innerHTML));
expect(followingList1).toEqual([party2]);
// Add Party 3 as well and check both are in the list.
await follow(page1, party3);
await page1.waitForSelector('.test-select-following');
const followingList11 = await page1.$$eval('.test-select-following', following => following.map(e => e.innerHTML));
expect(followingList11).toHaveLength(2);
expect(followingList11).toContain(party2);
expect(followingList11).toContain(party3);
// Log in as Party 2.
const page2 = await newUiPage();
await login(page2, party2);
// Party 2 should initially follow no one.
const noFollowing2 = await page2.$$('.test-select-following');
expect(noFollowing2).toEqual([]);
// However, Party 2 should see Party 1 in the network.
await page2.waitForSelector('.test-select-user-in-network');
const network2 = await page2.$$eval('.test-select-user-in-network', users => users.map(e => e.innerHTML));
expect(network2).toEqual([party1]);
// Follow Party 1 using the 'add user' icon on the right.
await page2.waitForSelector('.test-select-add-user-icon');
const userIcons = await page2.$$('.test-select-add-user-icon');
expect(userIcons).toHaveLength(1);
await userIcons[0].click();
// Also follow Party 3 using the text input.
// Note that we can also use the icon to follow Party 3 as they appear in the
// Party 1's Network panel, but that's harder to test at the
// moment because there is no loading indicator to tell when it's done.
await follow(page2, party3);
// Check the following list is updated correctly.
await page2.waitForSelector('.test-select-following');
const followingList2 = await page2.$$eval('.test-select-following', following => following.map(e => e.innerHTML));
expect(followingList2).toHaveLength(2);
expect(followingList2).toContain(party1);
expect(followingList2).toContain(party3);
// Party 1 should now also see Party 2 in the network (but not Party 3 as they
// didn't yet started following Party 1).
await page1.waitForSelector('.test-select-user-in-network');
const network1 = await page1.$$eval('.test-select-user-in-network', following => following.map(e => e.innerHTML));
expect(network1).toEqual([party2]);
// Log in as Party 3.
const page3 = await newUiPage();
await login(page3, party3);
// Party 3 should follow no one.
const noFollowing3 = await page3.$$('.test-select-following');
expect(noFollowing3).toEqual([]);
// However, Party 3 should see both Party 1 and Party 2 in the network.
await page3.waitForSelector('.test-select-user-in-network');
const network3 = await page3.$$eval('.test-select-user-in-network', following => following.map(e => e.innerHTML));
expect(network3).toHaveLength(2);
expect(network3).toContain(party1);
expect(network3).toContain(party2);
await page1.close();
await page2.close();
await page3.close();
}, 40_000);
test('error when following self', async () => {
const party = getParty();
const page = await newUiPage();
const dismissError = jest.fn(dialog => dialog.dismiss());
page.on('dialog', dismissError);
await login(page, party);
await follow(page, party);
expect(dismissError).toHaveBeenCalled();
await page.close();
});
test('error when adding a user that you are already following', async () => {
const party1 = getParty();
const party2 = getParty();
const page = await newUiPage();
const dismissError = jest.fn(dialog => dialog.dismiss());
page.on('dialog', dismissError);
await login(page, party1);
// First attempt should succeed
await follow(page, party2);
// Second attempt should result in an error
await follow(page, party2);
expect(dismissError).toHaveBeenCalled();
await page.close();
});

View File

@ -0,0 +1,10 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import ReactDOM from 'react-dom';
import 'semantic-ui-css/semantic.min.css';
import './index.css';
import App from './components/App';
ReactDOM.render(<App />, document.getElementById('root'));

View File

@ -0,0 +1,4 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
/// <reference types="react-scripts" />

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [
"src"
]
}