Story Details 페이지 구현

이번에는 Story Details 페이지를 꾸며 보겠습니다. 여기서 가장 중요한 것은 바로 특정 주소의 DreamStory 내용을 읽어오는 것입니다. 그래야 화면에 내용을 뿌릴 수 있겠죠. 이제 다음과 같은 페이지를 실제로 구현할 것입니다.


dream_story.js 생성

가장 먼저 할 일은 배포된 DreamStory 컨트랙트와 인터페이스 하는 것입니다. 이전에 DreamFactory와 인터페이스 하던 것과 유사합니다. DreamFactory의 경우 사전에 배포된 컨트랙트의 주소를 알기 때문에 하드 코딩으로 파일에 입력해서 사용했습니다. 그러나 DreamStory의 경우는 다릅니다.

DreamStory 컨트랙트의 주소는 DreamFactory 인스턴스가 동적으로 넘겨줍니다. 따라서 다음과 같이 새롭게 dream_story.js 파일을 생성합니다. 위치는 ethereum 폴더 밑입니다.

// import the web3 instance
import web3 from './web3';
// import compiled DreamStory which includes interface and bytecode
import DreamStory from './build/DreamStory.json';

// export a function that creates a new instance using the received address
export default address => {
    return new web3.eth.Contract(
      JSON.parse( DreamStory.interface ),
      address
    );
};


DreamFactory 인스턴스가 넘겨준 컨트랙트 address를 받아서 DreamStory 컨트랙트의 인스턴스를 생성합니다. 이때 인스턴스를 생성하는 방법은 배포해서 얻는 게 아니라 기존에 배포된 주소를 입력하여 새롭게 인스턴스를 생성했습니다.


story_details.js 수정

앞서 내용을 채우지 않은 채로 뒀던 story_details.js를 수정할 차례입니다. 좀 전에 만들었던 dream_story.js 파일을 이용하여 특정 DreamStory의 내용을 읽을 수 있습니다. 우선 코드 전체를 표시합니다.

// import react
import React, { Component } from 'react';
// import layout
import Layout from '../../components/layout';
// import DreamStory instance
import dream_story from '../../ethereum/dream_story';


// class based component
class StoryDetails extends Component {
  // get initial properties
  // the DreamStory address can be obtained from the argument props using the url
  // since the url includes the contract address
  static async getInitialProps( props ) {
    // get the DreamStory instance of the address
    const story= dream_story( props.query.address );
    // get summary of the story
    const summary= await story.methods.getSummary().call();
    // return the summary with labels
    return {
      address: props.query.address,
      balance: summary[0],
      votes_count: summary[1],
      downloads_count: summary[2],
      min_down_price_wei: summary[3],
      approvers_count: summary[4],
      author: summary[5],
      story_title: summary[6],
      story: summary[7]
    };
  }
  render() {
    return (
      <Layout>
        <h3>Story Details</h3>
      </Layout>
    )
  }
}

// export the component
export default StoryDetails;


DreamStory 인스턴스 획득

특정 주소의 DreamStory 인스턴스를 얻기 위해서는 좀 전에 작성한 dream_story.js를 이용합니다.

// import DreamStory instance
import dream_story from '../../ethereum/dream_story';
(생략)
  // get initial properties
  // the DreamStory address can be obtained from the argument props using the url
  // since the url includes the contract address
  static async getInitialProps( props ) {
    // get the DreamStory instance of the address
    const story= dream_story( props.query.address );
(생략)


페이지를 표시하기 전에 표시할 내용을 미리 읽어오기 위해서는 getInitialProps 함수를 사용해야 한다고 했습니다. index 페이지의 getInitialProps 함수와 달리 여기서는 props라는 인자를 받고 있습니다. 그렇습니다. 이 인자에는 페이지 URL 정보를 담고 있습니다. 그래서 props.query.address를 이용하면 URL을 얻을 수 있습니다. 그런데 이 URL이 바로 무엇인가요? 우리가 애초에 URL을 컨트랙트 주소로 했습니다. 이 URL에 포함된 주소를 dream_story에 넘기면 해당 주소를 갖는 컨트랙트의 인스턴스를 얻을 수 있습니다.


DreamStory 내용 접근

DreamStory의 인스턴스를 얻었기 때문에, 이제 인스턴스를 통해서 컨트랙트에 접근해서 정보를 읽을 수 있습니다. DreamStory에서는 다음과 같이 필요한 정보를 한 번에 읽어오는 getSummary함수를 만들었었습니다. 이 함수를 이용해서 정보를 얻어오면 됩니다.

function getSummary() public view returns ( uint, uint, uint, uint, uint, address, string, string )
{
    return (
      address(this).balance,
      votes_count,
      downloads.length,
      min_down_price_wei,
      approvers_count,
      author,
      story_title,
      story
    );
}


story_details.js 소스 전문에서 다음 부분이 컨트랙트 정보를 읽는 부분입니다.

// get summary of the story
    const summary= await story.methods.getSummary().call();
    // return the summary with labels
    return {
      address: props.query.address,
      balance: web3.utils.fromWei( summary[0], 'ether' ),
      votes_count: summary[1],
      downloads_count: summary[2],
      min_down_price: web3.utils.fromWei( summary[3], 'ether' ),
      approvers_count: summary[4],
      author: summary[5],
      story_title: summary[6],
      story: summary[7]
    };

여기서 summary의 결과를 의미를 부여하기 위해 풀었습니다. 이 정보를 받는 쪽에서 보다 편리하게 내용을 파악할 수 있도록 말이죠. 주의할 것은 getSummary 함수의 결과와 순서를 잘 맞춰야 합니다.

getInitialProps에서 설정한 내용은 인스턴스의 props에 저장되어 {this.props.address}와 같이 다른 곳에서 이용할 수 있습니다.


컴포넌트 구성

페이지 구성을 위해 semantic-ui-react에서 적절한 컴포넌트를 찾아봤습니다. new_story 페이지에서 사용했던 Input, TextArea를 그대로 사용하면 좋겠지만, 이것은 입력하는 용도입니다. 그리고 여기에 disabled 옵션을 줘서 표시할 수도 있긴 하지만, 내용이 흐리게 표시되어 쓰지 않는 걸로 했습니다.

대신에 Grid와 Container를 조합해서 Story 내용과 제목을 표시하게 하고 DreamStory 컨트랙트의 수치 정보는 Card를 이용해서 나타내도록 하겠습니다.

페이지 전체 구성은 대략 이렇습니다.


예쁘지 않나요? React가 생소해서 이것저것 시도해 보고 구성해 본 것입니다. 개인적으로 매우 만족합니다. 특히 Card에 작은 아이콘들이 너무 예쁩니다. 페이지 구성은 원하는 스타일대로 구성하면 됩니다.

사실 여기에는 내용이 다 들어가 있습니다. 컴포넌트 구성과 내용 채우는 것을 동시에 알아보겠습니다. story_details.js 파일을 다음과 같이 수정합니다.

// import Form, Button from semantic-ui-react
import { Card, Icon } from 'semantic-ui-react';
// import Grid, Input, Form, Message, Button
import { Grid, Input, Form, Message, Button } from 'semantic-ui-react';
// import Container and Header
import { Container, Header } from 'semantic-ui-react';
(생략)
render() {
    return (
      <Layout>
        <h2>Story Details</h2>
        <Grid>
          <Grid.Column width={10}>
            <Container text>
              <Header as='h3'>{this.props.story_title}</Header>
              <p>{ this.props.story }</p>
            </Container>
          </Grid.Column>
          <Grid.Column width={6}>
            <Card>
              <Card.Content header='Statistics' />
              <Card.Content extra>
                <Icon name='dollar sign' />
                {this.props.balance} (balance, ether)
              </Card.Content>
              <Card.Content extra>
                <Icon name='user' />
                {this.props.votes_count} (votes)
              </Card.Content>
              <Card.Content extra>
                <Icon name='download' />
                {this.props.downloads_count} (downloads)
              </Card.Content>
              <Card.Content extra>
                <Icon name='cart arrow down' />
                {this.props.min_down_price} (download price, ether )
              </Card.Content>
            </Card>
            { this.renderActionButtons() }
          </Grid.Column>
        </Grid>
      </Layout>
    )
  }
(생략)

필요한 컴포넌트를 import합니다. 컴포넌트 배치는 코드를 보면 알 수 있습니다. 이때, 컴포넌트에 채울 내용은 앞서 말했듯이 {this.props.xxxxx} 방식으로 합니다. getInitialProps 함수에서 우리가 props에 내용들을 추가해 줬기 때문에 가능한 것입니다. 예를 들어 story를 출력하는 곳에 {this.props.story}라고 입력하면 됩니다. 매우 쉽습니다.


화면 구성을 본인의 입맛에 맛게 예쁘게 꾸며보세요



이번에는 다음 그림과 같이 버튼을 만들고, 버튼이 눌렸을 때 트랜잭션을 처리하고, 또 다른 페이지로 이동하는 것을 구현해 보겠습니다.


버튼 컴포넌트 추가

먼저 버튼을 만들어야겠죠? 버튼 만드는 것은 매우 간단한데, 버튼을 눌렀을 때 이벤트 처리하는 것이 좀 복잡합니다. 그런데 이 작업은 new_story 페이지 만들 때 해봤었습니다. 그렇죠? 그래서 다 할 수 있습니다!

우선 버튼 컴포넌트를 화면에 표시하기 위해 다음과 같이 별도의 함수 'renderActionButtons`를 만듭니다. Contribute 버튼뿐 아니라, View Download, Request Download 버튼도 같이 만듭니다. 그런데 이벤트 핸들링하는 부분과 에러 상태를 체크하는 부분, 트랜잭션이 진행 중이면 버튼이 스피닝하는 것 등이 이미 들어가 있습니다. 이 내용은 이미 앞에서 다뤄 본 내용이라 설명 없이 추가했습니다.

  renderActionButtons() {
    return (
      <Form onSubmit={this.onContribute} error={!!this.state.error_msg}>
        <Form.Field>
          <label>Amount to contribute</label>
          <Input
            label="ether"
            labelPosition="right"
            placeholder='0.001'
            value={ this.state.contribute_price }
            onChange={ event => this.setState( { contribute_price: event.target.value } ) }
          />
        </Form.Field>
        <Message error header="Failed!" content={ this.state.error_msg } />
        <Button loading={this.state.loading} primary>Contribute</Button>
        <p></p>
        <Link route={`/dream_stories/${this.props.address}/downloads_list`}>
          <a>
            <Button primary>View Downloads</Button>
          </a>
        </Link>
        <p></p>
        <Link route={`/dream_stories/${this.props.address}/request_download`}>
          <a>
            <Button primary>Request Download</Button>
          </a>
        </Link>
      </Form>
    );
  }


라우트 맵핑 추가

한 가지 눈여겨 볼 것은 Link 태그입니다.

  • <Link route={/dream_stories/${this.props.address}/downloads_list}>
  • <Link route={/dream_stories/${this.props.address}/request_download}>

getInitialProps에서 저장한 address를 이용하여 새롭운 페이지에 접속하는 라우트입니다. 그러나 지금 이를 나타내기 위한 페이지가 없습니다. 따라서 먼저 이 두 페이지를 다음과 같이 간단히 만듭니다. 다음 코드는 downloads_list.js 파일입니다. 위치는 pages/dream_stories 밑입니다.

import React, {Component } from 'react';
import Layout from '../../components/layout';

class DownloadsList extends Component {
  render() {
    return (
      <Layout>
        <h3>Downloads List</h3>
      </Layout>
    );
  }
}

export default DownloadsList;


request_download.js 파일도 이와 유사하게 만듭니다. 여기서는 별도로 코드를 나타내지 않겠습니다. 이 두 파일은 나중에 추가적으로 구현할 것이기 때문입니다.

페이지를 만들었다고 끝이 아닙니다. 위 페이지는 동적 라우팅을 사용하고 있습니다. 왜냐하면 URL에 컨트랙트 주소가 들어가기 때문입니다. 동적 라우팅을 어디서 관리한다구요? 이전에 만들었던 routes.js 파일에서 합니다! 네 그곳을 수정해 줘야 비로소 링크가 동작합니다.

routes
  .add( '/dream_stories/new_story', '/dream_stories/new_story' )
  .add( '/dream_stories/:address', '/dream_stories/story_details' )
  .add( '/dream_stories/:address/downloads_list', '/dream_stories/downloads_list')
  .add( '/dream_stories/:address/request_download', '/dream_stories/request_download');


이 routes.js 코드를 보면 하단에 2개의 라우트 맵핑이 추가되었습니다.

"renderActionButtons" 함수를 만들기는 했는데, 호출은 하지 않았습니다. 호출은 아래와 같은 위치로 해줍니다.

(생략)
              </Card.Content>
            </Card>
            { this.renderActionButtons() }
          </Grid.Column>
        </Grid>
      </Layout>
(생략)


페이지 이동은 테스트 하지 않겠습니다. 잘 이동할 것입니다.


버튼 이벤트 핸들러

남은 건 "Contribute" 버튼을 눌렀을 때 트랜잭션을 생성하고 처리하는 내용 뿐입니다. 이것도 앞에서 다뤄봤기 때문에 어렵지 않습니다. 먼저 코드부터 보시죠.

  // event handler for contribute button
  onContribute = async () => {
    // block default submitting the form
    event.preventDefault();
    // set button loading and clear error message
    this.setState({ loading: true, error_msg: '' });
    // catch any error while executing the following
    try {
      // get all accounts of a user and use the accounts[0] to contribute
      const accounts= await web3.eth.getAccounts();
      // get the DreamStory instance of the address
      const story= dream_story( this.props.address );
      // convert contribute price to wei
      const contribute_price_wei= web3.utils.toWei( this.state.contribute_price, 'ether' );
      // call contribute function using the user's first account
      // use metamask's functinality to estimate the gas limit
      await story.methods.contribute()
      .send({
        from: accounts[0],
        value: contribute_price_wei
      });
      // refresh the current page, so the getInitialProps re-runs
      Router.replaceRoute(`/dream_stories/${this.props.address}`);
    } catch (error) {
      this.setState( { error_msg: error.message } );
    }
    // clear loading
    this.setState({ loading: false });
  };

중요 부분은 다음과 같습니다.

  • const story= dream_story( this.props.address );를 통해서 컨트랙트의 인스턴스를 얻음
  • await story.methods.contribute().send({ from: accounts[0], value: min_down_price_wei });를 이용하여 contribute 실행. 이때 contribution 금액을 value에 넣어서 트랜잭션 보냄. 여기서 주의할 점은 accounts[0]은 브라우저에 접속한 유저의 첫번째 계정임. DreamFactory 계정이 아님.
  • 트랜잭션이 완료될 때까지 버튼은 대기 상태를 의미하도록 스피닝 함.
  • 트랜잭션이 종료되면 페이지를 새로고침


라우트를 이용한 페이지 새로고침

다른 부분은 이미 다뤄서 대충 알겠는데, 마지막 부분은 설명이 필요해 보입니다. "Contribute" 버튼을 눌러서 트랜잭션이 완료되면(마이닝 되면), 페이지가 자동으로 새로고침되어 컨트랙트의 내용들이 업데이트 되면 좋습니다. 바로 이를 위한 코드가 Router.replaceRoute(/dream_stories/${this.props.address});입니다.

이것의 동작원리는 현재의 URL을 함수의 인자로 바꾸는 것입니다. 즉 페이지를 다른 것으로 바꾸는 함수인데, 자기 자신으로 바꾸면 새로고침이 되는 원리입니다.

페이지가 새로고침이 되면 어떻게 컨트랙트의 내용들이 바뀌는 것일까요? 페이지가 새로고침 되기 전에 getInitialProps 함수가 호출되기 때문입니다. 그래서 페이지의 컴포넌트 컨트랙트의 최신값으로 업데이트된 상태가 됩니다.

이렇게 코딩하고 나면 Contribute 버튼이 잘 동작도 하고 트랜잭션이 완료되면 컨트랙트 정보도 자동으로 업데이트됩니다.



Contribute 테스트하다 보니, contribute 함수는 이미 contribute 한 사용자가 또 눌러도 에러 없이 동작하게 되어 있는 걸 알아챘습니다. 뭐 contribution은 한번만 하는 것보다 여러 번 하는게 좋을 수도 있으니 그냥 놔두겠습니다. 허나 동일한 사용자가 contribution을 여러 번하면 votes_count는 증가하는 문제가 있습니다. 이것은 손을 봐야 겠네요. 그러나 지금 컨트랙트를 재배포 하지는 않겠습니다. 나중에 다 정리되면 그때 재배포하려고 합니다. 컨트랙트의 contribute 함수 수정은 이렇게 하면 됩니다.

    function contribute() public payable {
        // check if the money is greater than zero
        require( msg.value > 0 );
        // increase the vote counts only if the sender is in the contributor list
        if( !contributors[msg.sender] )
        {
           votes_count++;
           // set contributor address to true
           contributors[ msg.sender ]= true;
        }
    }

사용자가 이미 contributor라면 votes_count는 증가하지 않고, contribution을 처음할 때만 증가하도록 했습니다.



오늘의 실습: 웹지식이 많지도 않은 사람들을 위해 누가 이렇게 고생해서 예쁜 라이브러리, 툴들을 만든 것일까요?