Načítání Facebook postů v Nette Framework
Na jednom projektu jsem narazil na potřebu zobrazovat posty z Facebooku na klientově stránce. Inu začal jsem psát prototyp jak bych danou věc řešil. Prototyp jsem "spíchnul" za hodinku, ale bylo to uděláno tak trošku na hulváta. Tak jsem si řekl že to postupně přepíšu tak jak by to třeba napsal nějaký zkušený programátor s Nette frameworkem. Na http://srazy.info/nettefwpivo jsem danou věc přednesl a Nette guruové mi přislíbili odbornější konzultace.
Článek není psán jako how-to (na takovém článku zapracuji) Je psán jak jsem postupoval, co jsem napsal, následně zavrhul nebo přepsal. Proto se nedivte, že tu třeba pracuji s Nette Cache, kterou v zápětí odstraním. Při čtení je dobré koukat na konkrétní commity a článek brát jako "přemýšlení nahlas" k těmto commitům.
Výsledkem je aplikace co naimportuje posty z dané Facebook zdi do databáze, v administraci si pak zvolíte, které posty chcete na své stránce zobrazit.
Techniky co se používají: Nette\Caching, Facebook SDK, Kdyby\Facebook, Nette\Database, Custom Latte Macro, Snippets.
Celý projekt je k naleznutí na Githubu https://github.com/chemix/Nette-Facebook-Reader

Vytvoření projektu z Nette/Sandbox

Začneme čistým projektem vycházející z Nette/Sandbox
composer create-project nette/sandbox Nette-Facebook-Reader
cd Nette-Facebook-Reader
zápis do "log" a "temp" folderu
chmod -R a+rw temp log

Pročištění pískoviště

vyčistíme homepage šablonu a připravíme si nový Import Presenter se šablonou.
/app/templates/Import/default.latte
1
{block #content}
2
<h1>Import</h1>
Copied!
a /app/presenters/ImportPresenter.php
1
namespace App\Presenters;
2
3
use Nette,
4
App\Model;
5
6
class ImportPresenter extends BasePresenter
7
{
8
9
public function renderDefault()
10
{
11
12
}
13
14
}
Copied!

Přidáme trošku Facebooku

Přidáme si do composer.json závislost na Facebook SDK
1
"require": {
2
"php": ">= 5.3.7",
3
"nette/nette": "~2.2.0",
4
"dg/adminer-custom": "~1.0",
5
"facebook/php-sdk-v4" : "4.0.*"
6
},
Copied!
a stahneme si ho pomoci composer update.
Přihlásime se na Facebook Developers http://developers.facebook.com a vytvoříme novou aplikaci.
[ /data/articles/1/facebook-developer.png .(Facebook Developer) ]
Stačí nám vyplnit pouze její název
[ /data/articles/1/create-new-app.png .(Create new App) ]
a zjistíme si Facebook App ID a App Secret
[ /data/articles/1/facebook-app-ids.png .(Facebook App IDs) ]
Na hulváta si zkusíme načíst data skrze Facebook Graph Api. V našem Import Presenteru přidáme do hlavičky
1
use Facebook\FacebookRequest;
2
use Facebook\FacebookRequestException;
3
use Facebook\FacebookSession;
4
use Tracy\Dumper;
Copied!
a metodu přepíšeme podle ukázky z dokumentace k PHP Facebook SDK
1
public function renderDefault()
2
{
3
FacebookSession::setDefaultApplication('YOUR_APP_ID', 'YOUR_APP_SECRET');
4
5
$session = FacebookSession::newAppSession();
6
$data = array();
7
8
try {
9
$request = new FacebookRequest($session, 'GET', '/nettefw/feed');
10
$response = $request->execute();
11
$posts = $response->getGraphObject()->asArray();
12
13
$data = $posts['data'];
14
} catch (FacebookRequestException $ex) {
15
// Session not valid, Graph API returned an exception with the reason.
16
echo $ex->getMessage();
17
exit();
18
} catch (\Exception $ex) {
19
// Graph API returned info, but it may mismatch the current app or have expired.
20
echo $ex->getMessage();
21
exit();
22
}
23
24
Dumper::dump($data);
25
}
Copied!
po zkouknutí výstupu bychom měli vidět dump pole o 25 položkách
[ /data/articles/1/dump-array-25.png .(dump array) ]
Malinko si zjednodušíme ošetření chyb jen na ukončení aplikace.
1
} catch (\Exception $ex) {
2
throw $ex;
3
$this->terminate();
4
}
Copied!

Cache, ať při vývoji nečekáme

Přidáme si možnost cache pro požadavek. (Hlavně si tím urychlíme další rozšiřování, přeci jen čekat 10 sec na každý refresh mě nebaví).
O cachovaní se nám postara Nette Framework a jeho Nette\Caching\Cache;
Přidáme do use sekce
1
use Nette\Caching\Cache;
Copied!
Abychom mohli vytvořit instanci třídy Cache tak jí musíme předat nějaký cache storage kam si bude ukládat data. Viz API dokumentace Nette\Caching\Cache
A ten si necháme poslat (injectnout) do třídy pomoci Nette Dependenci Injection. Jediné co musíme udělat je definovat public property $cacheStorage typu \Nette\Caching\IStorage a pomocí anotace @inject nám framework zařídí vše potřebné.
1
class ImportPresenter extends BasePresenter
2
{
3
/**
4
* @var \Nette\Caching\IStorage @inject
5
*/
6
public $cacheStorage;
Copied!
hup, a v našich metodách se ke storage dostaneme snadno pomocí $this->cacheStorage
1
$cache = new Cache($this->cacheStorage, 'facebookWall');
2
$data = $cache->load("stories");
Copied!
Více o cache si nastudujete v dokumentaci Caching.
V našem případě pokud se nám nepodaří načíst data z cache (a to se nám napoprvé určitě nepovede) tak si je načteme z Facebooku a do té cache si je uložíme:
1
$cache->save("stories", $data, array(
2
Cache::EXPIRATION => '+30 minutes',
3
Cache::SLIDING => TRUE
4
));
Copied!
Výsledkem je
1
public function renderDefault()
2
{
3
FacebookSession::setDefaultApplication('YOUR_APP_ID', 'YOUR_APP_SECRET');
4
5
$session = FacebookSession::newAppSession();
6
$cache = new Cache($this->cacheStorage, 'facebookWall');
7
$data = $cache->load("stories");
8
9
if (empty($data)) {
10
try {
11
$request = new FacebookRequest($session, 'GET', '/nettefw/feed');
12
$response = $request->execute();
13
$posts = $response->getGraphObject()->asArray();
14
15
$data = $posts['data'];
16
$cache->save("stories", $data, array(
17
Cache::EXPIRATION => '+30 minutes',
18
Cache::SLIDING => TRUE
19
));
20
21
} catch (\Exception $ex) {
22
throw $ex;
23
$this->terminate();
24
}
25
}
26
27
Dumper::dump($data);
28
}
Copied!
Nyní by nám druhý request měl trvat výrazně kratší dobu.
[ /data/articles/1/with-cache.png .(with cache) ]

Ukládáme posty do databáze

Dalším krokem je uložit si získána data do databáze. Pro práci s databází použijeme třídu Nette\Database. Vytvoříme si databázi a uživatele (díky klonování Nette Sandbox máme výborný nástroj Adminer přímo u projektu /adminer/).
Uživatel bude facebookwall a s heslem 'tajneheslo' bude mít přístup ke všem databázím začínající facebookwall_ v našem vývojovém případě konkrétně k databázi facebookwall_devel
1
CREATE DATABASE `facebookwall_devel` COLLATE 'utf8_czech_ci';
2
CREATE USER 'facebookwall'@'localhost' IDENTIFIED BY 'tajneheslo';
3
GRANT USAGE ON * . * TO 'facebookwall'@'localhost' IDENTIFIED BY 'tajneheslo' WITH MAX_QUERIES_PER_HOUR 0 MAX_CONNECTIONS_PER_HOUR 0 MAX_UPDATES_PER_HOUR 0 MAX_USER_CONNECTIONS 0 ;
4
GRANT ALL PRIVILEGES ON `facebookwall\_%` . * TO 'facebookwall'@'localhost';
5
FLUSH PRIVILEGES;
Copied!
a vytvoříme si tabulku kam si posty budeme ukládat.
1
CREATE TABLE `facebook_wallposts` (
2
`id` varchar(100) CHARACTER SET ascii NOT NULL,
3
`created_time` datetime NOT NULL,
4
`updated_time` datetime NOT NULL,
5
`type` varchar(100) CHARACTER SET ascii NOT NULL,
6
`status_type` varchar(250) COLLATE utf8_czech_ci NOT NULL,
7
`name` varchar(250) COLLATE utf8_czech_ci NOT NULL,
8
`link` varchar(250) COLLATE utf8_czech_ci NOT NULL,
9
`message` text COLLATE utf8_czech_ci NOT NULL,
10
`caption` text COLLATE utf8_czech_ci NOT NULL,
11
`picture` varbinary(250) NOT NULL,
12
`status` char(1) COLLATE utf8_czech_ci NOT NULL DEFAULT '0',
13
PRIMARY KEY (`id`),
14
KEY `type` (`type`),
15
KEY `created_time` (`created_time`)
16
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_czech_ci;
Copied!
V presenteru si řekneme ať nám framework předá objekt Nette\Database\Context
1
/**
2
* @var \Nette\Database\Context @inject
3
*/
4
public $database;
Copied!
v konfiguračním souboru /app/config/config.local.neon si doplňíme připojení k databázi.
1
nette:
2
database:
3
dsn: 'mysql:host=127.0.0.1;dbname=facebookwall_devel'
4
user: facebookwall
5
password: tajneheslo
6
options:
7
lazy: yes
Copied!
Pozor na zápis, liší se od Dibi a občas mě to dokáže zabrzdit ;-)
V presenteru si pak na hulváta doplníme ukládání jednotlivých řádku, připadne po opakovaném importu aktualizaci postit
1
// save data to database
2
if (is_array($data) && !empty($data)) {
3
foreach ($data as $rowPost) {
4
5
$post = array(
6
'id' => $rowPost->id,
7
'type' => $rowPost->type,
8
'created_time' => $rowPost->created_time,
9
'updated_time' => $rowPost->updated_time,
10
);
11
12
if (isset($rowPost->name)) {
13
$post['name'] = $rowPost->name;
14
}
15
if (isset($rowPost->link)) {
16
$post['link'] = $rowPost->link;
17
}
18
if (isset($rowPost->status_type)) {
19
$post['status_type'] = $rowPost->status_type;
20
}
21
if (isset($rowPost->message)) {
22
$post['message'] = $rowPost->message;
23
}
24
if (isset($rowPost->caption)) {
25
$post['caption'] = $rowPost->caption;
26
}
27
if (isset($rowPost->picture)) {
28
$post['picture'] = $rowPost->picture;
29
}
30
31
// post 'status' use story, we need message
32
if ($rowPost->type == 'status') {
33
if (isset($rowPost->story)) {
34
$post['message'] = $rowPost->story;
35
}
36
}
37
38
try {
39
$this->database->table('facebook_wallposts')->insert($post);
40
} catch (\PDOException $e) {
41
if ($e->getCode() == '23000') {
42
$this->database->table('facebook_wallposts')->where('id', $rowPost->id)->update($post);
43
} else {
44
throw $e;
45
}
46
}
47
}
48
}
Copied!
můžeme se přesvědčit, že se nám vše uložilo
[ /data/articles/1/save-to-database.png .(save posts to database) ]

Zobrazení postů z importu

Předáme si výpis práve přidaných postů do šablony a tam si je vypíšeme.
1
// send data to template
2
$this->template->wallPosts = $data;
Copied!
a
1
{foreach $wallPosts as $post}
2
type: <strong>{$post->type}</strong> <br>
3
<small>
4
id: {$post->id}<br>
5
created_time: {$post->created_time} <br>
6
updated_time: {$post->updated_time} <br>
7
</small>
8
9
{ifset $post->name}
10
name: <h1>{$post->name}</h1>
11
{/ifset}
12
13
{ifset $post->status_type}
14
status_type: {$post->status_type} <br>
15
{/ifset}
16
17
{ifset $post->message}
18
message: {$post->message} <br>
19
{/ifset}
20
21
{ifset $post->picture}
22
piscture: {$post->picture} <br>
23
<img src="{$post->picture}" />
24
{/ifset}
25
26
{ifset $post->link}
27
link: <a href="{$post->link}">{$post->link}</a> <br>
28
{/ifset}
29
30
{ifset $post->caption}
31
caption: {$post->caption} <br>
32
{/ifset}
33
<hr>
34
{/foreach}
Copied!

Zobrazení postů na homepage

V HomepagePresenteru si načteme posty co jsme načetly importem. Jelikož, ale nechcem zobrazovat všechny posty nastavíme si u některých v databázi status na 1 a budem zobrazovat pouze tyto.
1
public function renderDefault()
2
{
3
$facebookWallPosts = $this->database->table('facebook_wallposts')->where('status','1')->limit(5)->fetchAll();
4
$this->template->wallPosts = $facebookWallPosts;
5
}
Copied!
a nesmíme zapomenout na přidání public property $database;
1
/**
2
* @var \Nette\Database\Context @inject
3
*/
4
public $database;
Copied!
šablona pak může vypadat nějak takto:
1
<div n:if="$wallPosts" class="facebook-posts">
2
<div n:foreach="$wallPosts as $post" class="post {$post->type}">
3
<h3 n:if="$post->name">{$post->name}</h3>
4
<img n:if="$post->picture" src="{$post->picture}" />
5
6
{if $post->message}
7
<p>
8
{$post->message|truncate:250:'...'}
9
<a n:if="$post->link" href="{$post->link}">více</a>
10
</p>
11
{else}
12
{if $post->link}
13
<a href="{$post->link}">více</a>
14
{else}
15
<a href="http://www.facebook.com/nettefw">více</a>
16
{/if}
17
{/if}
18
</div>
19
</div>
Copied!
tuto verzi najdete pod tagem :prototype

Zapouzdření do modelu

Pokud se nad úkolem zamyslíme tak je to taková věc co by se nám mohla hodit i na jiném projektu. Připravíme si tedy modelovou vrstvu. Do ktere přepíšeme náš prototyp.
Všiměte si jak si v konstruktoru řekneme o Nette\Database\Context
app/model/FacebookWallpost.php
1
namespace App\Model;
2
3
use Nette\Database\Context;
4
use Nette\Object;
5
6
class FacebookWallposts extends Object
7
{
8
/**
9
* @var \Nette\Database\Context
10
*/
11
protected $database;
12
13
function __construct(Context $database)
14
{
15
$this->database = $database;
16
}
17
18
public function getLastPosts($count = 5)
19
{
20
return $this->database->table('facebook_wallposts')
21
->where('status', '1')
22
->order('created_time DESC')
23
->limit($count)
24
->fetchAll();
25
}
26
}
Copied!
a v presenteru přepíšeme vypisování postů na
1
/**
2
* @var \App\Model\FacebookWallposts @inject
3
*/
4
public $wallposts;
5
6
public function renderDefault()
7
{
8
$this->template->wallPosts = $this->wallposts->getLastPosts();
9
}
Copied!
Property $database jsme nahradili za $wallpost a změnili typ třídy co chceme po frameworku aby nám předal. Aby to celé fungovalo musíme ješte danou servisu zaregitrovat v config.neon
1
services:
2
- App\Model\UserManager
3
- App\RouterFactory
4
router: @App\RouterFactory::createRouter
5
- App\Model\FacebookWallposts
Copied!

Import do modelu

To samé uděláme i s částí pro načítání dat z Facebooku.
Při přesunu odstraním používání cache, jelikož už jí při vývoji nepotřebuji ba naopak pokud chci zadat import tak chci aby se provedl vždy.
1
public function importPostFromFacebook()
2
{
3
FacebookSession::setDefaultApplication('YOUR_APP_ID', 'YOUR_APP_SECRET');
4
$session = FacebookSession::newAppSession();
5
6
try {
7
$request = new FacebookRequest($session, 'GET', '/nettefw/feed');
8
$response = $request->execute();
9
$posts = $response->getGraphObject()->asArray();
10
$data = $posts['data'];
11
12
} catch (\Exception $ex) {
13
throw $ex;
14
$this->terminate();
15
}
16
17
// save data to database
18
...
Copied!
z Import presenteru přemístíme use sekci do modelu a presenter se nám rázem zjednodušil na
1
class ImportPresenter extends BasePresenter
2
{
3
/**
4
* @var \App\Model\FacebookWallposts @inject
5
*/
6
public $wallposts;
7
8
public function renderDefault()
9
{
10
$this->template->wallPosts = $this->wallposts->importPostFromFacebook();
11
}
12
13
}
Copied!

A co to heslo v kódu? Pryč s nim

Jako pěkný, ale. Ale nám se ještě nelíbí
1
FacebookSession::setDefaultApplication('YOUR_APP_ID', 'YOUR_APP_SECRET');
2
$session = FacebookSession::newAppSession();
Copied!
hesla chceme v konfiguraci a zde si jen řekneme o funkční session. Nahradíme tedy za
1
$session = $this->facebookSessionManager->getAppSession();
Copied!
a do konstruktoru přidáme předání závislosti. plus nezapomene na deklarovaní property.
1
/**
2
* @var \App\Model\FacebookSessionManager
3
*/
4
protected $facebookSessionManager;
5
6
/**
7
* @param Context $database
8
* @param FacebookSessionManager $facebook
9
*/
10
function __construct(Context $database, FacebookSessionManager $facebook)
11
{
12
$this->database = $database;
13
$this->facebookSessionManager = $facebook;
14
}
Copied!
a našeho "hloupoučkého" managera definujeme v /app/model/FacebookSessionManager.php
1
namespace App\Model;
2
3
use Facebook\FacebookSession;
4
5
class FacebookSessionManager {
6
7
protected $appId;
8
9
protected $appSecret;
10
11
function __construct($appId, $appSecret)
12
{
13
$this->appId = $appId;
14
$this->appSecret = $appSecret;
15
16
FacebookSession::setDefaultApplication($this->appId, $this->appSecret);
17
}
18
19
public function getAppSession()
20
{
21
return FacebookSession::newAppSession();
22
}
23
}
Copied!
registrujeme ho v config.neon
1
services:
2
- App\Model\UserManager
3
- App\RouterFactory
4
router: @App\RouterFactory::createRouter
5
- App\Model\FacebookWallposts
6
- App\Model\FacebookSessionManager(%facebook.appId%, %facebook.appSecret%)
Copied!
a v config.local.neon přidáme sekci s kódem a heslem k aplikaci
1
parameters:
2
facebook:
3
appId: APP_ID
4
appSecret: APP_SECRET
Copied!
tuto verzi najdete pod tagem :model-di

Korektury od Nette Guru

Jako první poslal pull request Filip Prochazka.
commit: removed useless folder odebírá zbytečné kontrolování složky kterou nepoužíváme
commit: Refactored default configuration for database Upravuje jak se zapisuje přihlašování k databázi. Nyní jsou parametry (jméno, heslo, server, databáze) vytáhnuté do sekce "parameters". Proto si ze souboru config.local.neon odeberte sekci nette - database a nahraďte ji
1
parameters:
2
database:
3
dbname: facebookwall_devel
4
user: facebookwall
5
password: tajneheslo
Copied!
mnohem čitelnější.
commit: export db schema to project přidává zmíněný SQL table creator do kódu
teď ale příjde ta zajímavější část.

Použití Kdyby/Facebook

Jako první nahradíme v composer.json Facebook/SDK za Kdyby/Facebook
1
"require": {
2
"php": ">= 5.3.7",
3
"nette/nette": "~2.2.0",
4
"dg/adminer-custom": "~1.0",
5
"kdyby/facebook" : "dev-master"
6
},
Copied!
a aktualizujeme composer pomocí composer update. Smažeme náš "hloupoučký" FacebookSessionManager.php a odebereme i jeho registraci do services v config.neon, zde naopak přidáme sekci extensions a do ní registrujeme Kdyby Facebook
1
extensions:
2
facebook: Kdyby\Facebook\DI\FacebookExtension
3
4
services:
5
- App\Model\UserManager
6
- App\RouterFactory
7
router: @App\RouterFactory::createRouter
8
- App\Model\FacebookWallposts
Copied!
Tato extension vyžaduje v configu Facebook App ID a Facebook Secret. Proto do lokálního config.local.neon přemístíme tyto informace ze sekce params (kam jsme si je uložili) do sekce facebook.
1
facebook:
2
appId: "APP_ID"
3
appSecret: "APP_SECRET"
Copied!
(APP_ID dejte do uvozovek, jinak je brán jako integer a Kdyby/Facebook vyhodí excaption.)
A ve FacebookWallpost.php uděláme pár změn. Prvně si upravíme use sekci. Odebereme Facebook SDK a nahradíme ho za Kdyby\Facebook a přidáme Tracy\Debugger.
1
use Kdyby\Facebook\Facebook;
2
use Kdyby\Facebook\FacebookApiException;
3
use Nette\Database\Context;
4
use Nette\Object;
5
use Tracy\Debugger;
Copied!
Pak nahradíme inicializaci session managera za Kdyby\Facebook
1
/**
2
* @var Facebook
3
*/
4
protected $facebook;
5
6
/**
7
* @param Context $database
8
* @param Facebook $facebook
9
*/
10
function __construct(Context $database, Facebook $facebook)
11
{
12
$this->database = $database;
13
$this->facebook = $facebook;
14
}
Copied!
a zjednoduší se nám i volání samotného požadavku. Plus si budeme logovat případnou chybu.
1
public function importPostFromFacebook()
2
{
3
try {
4
$posts = $this->facebook->api('/nettefw/feed');
5
$data = $posts['data'];
6
7
} catch (\Exception $ex) {
8
Debugger::log($ex->getMessage(), 'facebook');
9
return array();
10
}
11
12
// save data to database
13
...
Copied!
Q: Proč return array(); namísto $this->terminate();
A: ???
Teď, když si znovu spustíme import, tak bychom měli v Tracy vidět novou ikonku Facebooku a u ní základní informace o volání jeho API. Krása.
[ /data/articles/1/kdyby-facebook-tracy.png .(Kdyby Facebook - Tracy extension) ]
Další vychytávkou co Kdyby\Facebook má je metoda iterate. Pokud jste si všimli tak volání Facebook API vrací cca 25 záznamů a adresu pro další (paging) toho tato metoda využívá umožnuje nad výsledkem iterovat třeba ve foreach a donačíst tak úplně všechny posty.
Nahradíme tedy volání api za iterate. Zde už dostáváme čisté "pole" všech postů tak poupravíme i samotné procházení výsledků.
1
try {
2
$posts = $this->facebook->iterate('/nettefw/feed');
3
4
} catch (\Exception $ex) {
5
Debugger::log($ex->getMessage(), 'facebook');
6
return array();
7
}
8
9
$imported = array();
10
// save data to database
11
foreach ($posts as $rowPost) {
12
...
13
}
14
return $imported;
Copied!
Když teď, zkusíme import, v Tracy panelu uvidíme že se Facebook Api volalo vícekrát a v panelu je vidět detail každého volání.
Filip pak přepsal mé ifové peklíčko do mnohem čitelnější podoby pomocí ternárního operátora "?:". Nahradil datum ve stringu za DateTime obalené v Nette\Utils\DateTime a vrácený záznam je přetypován na ArrayHash (nezapomenout definovat v use)
1
use Nette\Utils\DateTime;
2
use Nette\Utils\ArrayHash;
Copied!
a pak ve foreach
1
$post = array(
2
'id' => $rowPost->id,
3
'type' => $rowPost->type,
4
'created_time' => DateTime::from($rowPost->created_time)->format('Y-m-d H:i:s'),
5
'updated_time' => DateTime::from($rowPost->updated_time)->format('Y-m-d H:i:s'),
6
'name' => isset($rowPost->name) ? $rowPost->name : NULL,
7
'link' => isset($rowPost->link) ? $rowPost->link : NULL,
8
'status_type' => isset($rowPost->status_type) ? $rowPost->status_type : NULL,
9
'message' => isset($rowPost->message) ? $rowPost->message : NULL,
10
'caption' => isset($rowPost->caption) ? $rowPost->caption : NULL,
11
'picture' => isset($rowPost->picture) ? $rowPost->picture : NULL,
12
);
13
14
// post 'status' use story, we need message
15
if ($rowPost->type == 'status' && isset($rowPost->story)) {
16
$post['message'] = $rowPost->story;
17
}
18
// add to return array
19
$imported[$post['id']] = ArrayHash::from($rowPost);
Copied!

Latte filter

Filip do kódu přidal i ukázku jak se poprat s if peklem v šablonách pomocí Latte filtru.
V HomepagePresernter definujeme metodu createTemplate, která vrací presenteru template object, který se použije pro render šablon. K této šabloňe přidáme filtr, který se bude starat o odkazy na posty.
1
protected function createTemplate()
2
{
3
/** @var Nette\Bridges\ApplicationLatte\Template $template */
4
$template = parent::createTemplate();
5
$template->addFilter('fbPostLink', function ($fbPost) {
6
if (!empty($fbPost->link)) {
7
return $fbPost->link;
8
}
9
10
if ($m = Nette\Utils\Strings::match($fbPost->id, '~^(?P<pageId>[^_]+)_(?P<postId>[^_]+)\\z~')) {
11
return 'https://www.facebook.com/nettefw/posts/' . urlencode($m['postId']);
12
}
13
14
return NULL;
15
});
16
17
return $template;
18
}
Copied!
  • filter se jmenuje fbPostLink
  • pokud daný post má definovaný link vrací tento link
  • pokud se podaří z post id (které obsahuje {pageId}_{postId} ) získat postId vrátí link na konkrétní post na facebooku
  • voláme ho nad objektem $post
šablona se nám pak zjednoduší na
1
<div n:foreach="$wallPosts as $post" class="post {$post->type}">
2
<h3 n:if="$post->name">{$post->name}</h3>
3
<img n:if="$post->picture" src="{$post->picture}" />
4
<p>
5
{if $post->message}{$post->message|truncate:250:'...'}{/if}
6
<a href="{$post|fbPostLink}">více</a>
7
</p>
8
</div>
Copied!
tuto verzi najdete pod tagem :kdyby

Drobné vylepšováky

Na doporučení Davida jsem odebral z projektového .gitignore soubory Sublime editoru a PHPStormu a zapsal jsem si je do globálního gitignore podle návodu na githubu

Administrace postů

Jako poslední úkol jsem si nechal administraci na povolování postů. Vytvoříme si nový presenter AdminPresenter a u vypsaných jednotlivých položek přidáme tlačítko enable, disable. To celé pak z "AJAXujem".
Než začnem s php úpravama, provedem pár drobných změn na frontendu ať se na náš výtvor dá aspoň trošku koukat. Osobně mám rád Zurb Foundation, ale tu samou práci, pro tento případ i možná vhodnější, odvede Bootstrap
commit: tabs indent

Výkop administrace

Začneme úpravou modelu, kam si přidáme metodu co nám vrátí všechny posty.
1
public function getAllPosts()
2
{
3
return $this->database->table('facebook_wallposts')
4
->order('created_time DESC')
5
->fetchAll();
6
}
Copied!
a následně si je načteme presenterem AdminPresenter a pošleme do šablony
1
/**
2
* Admin presenter.
3
*/
4
class AdminPresenter extends BasePresenter
5
{
6
7
/**
8
* @var \App\Model\FacebookWallposts @inject
9
*/
10
public $wallposts;
11
12
public function renderDefault()
13
{
14
$this->template->wallPosts = $this->wallposts->getAllPosts();
15
}
16
17
}
Copied!
šablonu je možno vidět v commitu.

Akce disable, enable

Následuje vytvoření v modelu metod co se nám postarají o samotnou editaci postu.
1
/**
2
* enable post
3
*
4
* @param $postId string
5
* @return bool
6
*/
7
public function enablePost($postId)
8
{
9
$this->database->table('facebook_wallposts')
10
->where('id', $postId)
11
->update(array('status' => '1'));
12
return TRUE;
13
}
14
/**
15
* disable post
16
*
17
* @param $postId string
18
* @return bool
19
*/
20
public function disablePost($postId)
21
{
22
$this->database->table('facebook_wallposts')
23
->where('id', $postId)
24
->update(array('status' => '0'));
25
return TRUE;
26
}
Copied!
a v presenteru si vytvořím dvě akce co funkcionalitu budou obsluhovat
1
public function actionEnablePost($postId)
2
{
3
if ($this->wallposts->enablePost($postId)){
4
$this->flashMessage('Post enabled');
5
}
6
$this->redirect('default');
7
}
8
public function actionDisablePost($postId)
9
{
10
if ($this->wallposts->disablePost($postId)){
11
$this->flashMessage('Post disabled');
12
}
13
$this->redirect('default');
14
}
Copied!
v šabloně si upravím odkazy ať fungují
1
{if $post->status == '1'}
2
<li><a n:href="Admin:disablePost $post->id" class="button alert">disable</a></li>
3
{else}
4
<li><a n:href="Admin:enablePost $post->id" class="button">enable</a></li>
5
{/if}
Copied!
a fungujeme.

První verze zajaxovní

není to žádná hitparáda, ale funguje, a to se počítá ;-) Začnem úpravou presenteru. Ten pokud se bude jednat o ajaxový požadavek, tak na místo flashMessage nastavíme do proměnné payload message co se stalo, a následně pošleme uživateli tento payload. Metoda sendPayload je ulehčení ať se nemusíme starat o posílání JSON Response, vše je čitélne z obsahu metody sendPayload
1
public function actionEnablePost($postId)
2
{
3
if ($this->wallposts->enablePost($postId)){
4
$this->flashMessage('Post enabled');
5
if ($this->wallposts->enablePost($postId)) {
6
if ($this->isAjax()) {
7
$this->payload->message = 'Post enabled';
8
$this->sendPayload();
9
} else {
10
$this->flashMessage('Post enabled');
11
$this->redirect('default');
12
}
13
}
14
$this->redirect('default');
15
}
Copied!
to samé uděláme i pro druhou metoru actionDisablePost. Upravíme si šablonu tak že budem zobrazovat obě tlačítka a jen skrze css budem schovávat to, které zrovna nebudem potřebovat.
1
<li n:class="disable, !$post->status ? hide"><a n:href="Admin:disablePost $post->id" class="ajax button alert">disable</a></li>
2
<li n:class="enable, $post->status ? hide"><a n:href="Admin:enablePost $post->id" class="ajax button">enable</a></li>
Copied!
a pak celé to rozhejbání v JavaScriptu. Pokud kliknem na odkaz co má třídu .ajax tak stopnem klasické volání, a zavoláme XHR požadavek. Pokud se nám vrátí message, že je post disablován, tak prohodíme zobrazení tlačítek. (v opačném případě také) Plus pár visuálních drobností (disablování butonu po kliknutí, změna kurzoru na hodinky)
1
// Ajax click in admin
2
$('body').on('click', 'a.ajax', function (event) {
3
event.preventDefault();
4
event.stopImmediatePropagation();
5
var link = $(this);
6
if (link.hasClass('disabled')) {
7
return false;
8
}
9
link.css('cursor', 'wait');
10
link.addClass('disabled');
11
$.post(this.href, function (data) {
12
if (data.message == 'Post disabled') {
13
link.parent().parent().find('.disable').addClass('hide');
14
link.parent().parent().find('.enable').removeClass('hide');
15
} else {
16
// enabled
17
link.parent().parent().find('.disable').removeClass('hide');
18
link.parent().parent().find('.enable').addClass('hide');
19
}
20
link.removeClass('disabled');
21
link.css('cursor', 'default');
22
});
23
});
Copied!

Úprava JSON komunikace

Porovnávat message co se stalo není moc "profi", tak si zavedem nějakou proměnou s akcí a status zdali se provedla správně. To s použitím payload proměnné je vcelku snadné
1
if ($this->isAjax()) {
2
$this->payload->message = 'Post enabled';
3
$this->payload->action = 'enable';
4
$this->payload->status = '1';
5
$this->sendPayload();
6
7
} else {
Copied!
v JavaScriptu se pak zeptáme co se dělo a jak to dopadlo a zobrazíme dynamicky flash zprávu.
1
var flashMessage = function(message)
2
{
3
$($('body')[0]).prepend($('<div class="flash info">'+message+'</div>'));
4
}
5
6
...
7
8
if (payload.action == 'disable' && payload.status == '1') {
9
// disabled
10
link.parent().parent().find('.disable').addClass('hide');
11
link.parent().parent().find('.enable').removeClass('hide');
12
flashMessage(payload.message);
13
}
Copied!

Použij signály než action

Další radou od zkušených bylo použití signálů (handle).
Handle je na změnu stavu aktuálního view. Tj na smazání, zaktivnění položky etc. (Většinou totiž po provedení chceš znova vykreslit tu samou stránku).
Patrik Votoček
nebo
Handle je „subsignál“ aktuální akce, je to jako když odešleš formulář. Když máš akci, tak většinou by měla něco zobrazovat, nebo připravovat data pro formulář. Zpracování formuláře taky nedáváme do akce, ale napíšeme na to metodu, kterou dáš formuláři jako callback. Tak přesně to je signál, zpracování nějaké operace (třeba smazání řádků, nebo označení řádku jako hidden) pro aktuální akci (což je třeba výpis jednotlivých řádků).
Filip Procházka
Přepracování bylo snadné. Přejmenoval jsem metody z actionDisablePost na handleEnablePost a volání z
1
<a n:href="Admin:enablePost $post->id" class="ajax button">enable</a>
Copied!
na vykřičníkový signál
1
<a n:href="enablePost! $post->id" class="ajax button">enable</a>
Copied!
TIP: piš méně
pri odkazovani na akci ve stejnem presenteru staci uvest nazev akce, nemusis jiz uvadet presenter
Matej21

Snippety a nette.ajax.js

Teď přichází pořádné kladivo. Představme si že nechceme ručně ošetřovat ajaxové volání. Prostě ať se udělá co se udělat má a změní se jen potřebné. K tomu slouží Snippety. Snippet chápu jako pojmenovaný prvek na stránce, který v případě potřeby je možné nahradit za jeho aktuální verzi. V našem případě si pro začátek označíme dva snippety. Prvním bude blok kódu co se nám stará o výpis flash messsages
1
{snippet flashes}
2
<div n:foreach="$flashes as $flash" class="flash {$flash->type}">{$flash->message}</div>
3
{/snippet}
Copied!
druhým bude tabulka wallpostů
1
{snippet wallposts}
2
{foreach $wallPosts as $post}
3
<div class="row">
4
...
5
{/foreach}
6
{/snippet}
Copied!
v tuto chvíli se stali tzv. controllem který v případě, že víme že se změnil tak ho necháme překreslit redrawControl. V našem případě pokud chceme změnit status postu tak necháme překreslit snippet flashes a wallposts
1
public function handleEnablePost($postId)
2
{
3
if ($this->wallposts->enablePost($postId)) {
4
$this->flashMessage('Post enabled');
5
$this->redrawControl('flashes');
6
$this->redrawControl('wallposts');
7
}
8
}
Copied!
kód se nám dosti zjednodušil. Ještě dáme pryč celý náš JavaScript mechanismus co se staral o zpracování požadavku a použijeme knihovnu nette.ajax.js od Vojty Dobeše, která umí pracovat automaticky právě se snippety a s jejich přenosovým "JSON protokolem"
jediné co potřebujeme je zavolat její inicializaci.
1
$(function () {
2
$.nette.init();
3
});
Copied!
pěkné zjednodušení, že? Kabelama se nám přenáší jen co se "opravdu" změnilo
[ /data/articles/1/snippets-response.png .(json snippets response) ]

Bacha na F5

Zpracování formulářu v Nette funguje na bázi signálu (handle) a tam abychom se vyhnuli problému s refreshem použijeme redirect(). Stejně je tomu i v našem případě se signály na disablování a enablování postu. Pokud se nejedná o ajaxový požadavek, tak přesměrujem.
1
// F5 protection without JS
2
if (!$this->isAjax()){
3
$this->redirect('this');
4
}
Copied!

Posílání opravdu jen toho co je třeba

Ajaxové požadavky sviští o 106 jen se nám v každém requestu ajaxem posílá celá tabulka postů. Ale my změnili jen jeden, co kdyby se tedy posílal jen tenhle jeden spolu s flash message? Lze. Technika se nazývá dynamické snippety
každý řádek zabalíme do jednoznačne identifikovatelného snippetu (použijeme n makro)
1
{snippet wallposts}
2
{foreach $wallPosts as $post}
3
<div class="row" n:snippet="item-$post->id">
Copied!
a přidáme trochu logiky do handle. V případě že se jedná o ajaxový požadavek, načteme jen aktuálně zpracovávaný řádek a do šablony ho pošleme jako "seznam všech postů", v normálním požadavku pošleme do šablony posty všechny.
1
public function handleEnablePost($postId)
2
{
3
if ($this->wallposts->enablePost($postId)) {
4
$this->template->wallPosts = $this->isAjax()
5
? array($this->wallposts->getOne($postId))
6
: $this->wallposts->getAllPosts();
7
$this->flashMessage('Post enabled');
8
$this->redrawControl('flashes');
9
$this->redrawControl('wallposts');
Copied!
[ /data/articles/1/dynamic-snippets.png .(json dynamic snippets response) ]
jelikož se handle zpracovává dříve než render viz životní cyklus presenteru, tak pokud uživatel bez JavaScriptu změnil viditelnost postu, tak už do šablony poslal seznam všech postů a render už tuto věc dělat nemusí, tak si to ošetříme.
1
public function renderDefault()
2
{
3
if (!isset($this->template->wallPosts)) {
4
$this->template->wallPosts = $this->wallposts->getAllPosts();
5
}
6
}
Copied!

Zničíme duplicitní kód

Metody handleEnablePost a handleDisablePost($postId) mají dost kódu úplně stejného. Proto mě napadlo že bych je nějak předělal.
První nápad byl mít metodu handleChangePostStatus($postId, $actionType), kde by jako druhý parametr byl typ akce, disable nebo enable. Dva parametry se mi nakonec nelíbily.
TIP: rezervovaná slova
zde jsem původně měl parametr pojmenová pouze $action a ouhle nějak to nefungovalo. Narazil jsem na pojem rezervovaných proměnných. Tak bacha na ně ;-) Proto i submit button ve formuláři by neměl mít jméno action. Další slova jsou: $do, $_fid, (TODO) .. a $action
Druhým nápadem bylo mít metodu handleTogglePostStatus($postId), která by si zjistila zda je článek povolen a zakázala by ho nebo opačně. Zjistil jsem, že by status ani zjištovat nemusela jen by SQL update otočil hodnotu (nezkoušel jsem). Toto řešení jsem zavrhl kvůli zobrazení ve dvou oknech současně. Chování by mohlo být nelogické.
Třetím nápadem bylo vytáhnout společnou logiku do vlastní metody a u něj jsem zůstal.
1
protected function afterTogglePostStatus($status, $postId, $message)
2
{
3
if ($status) {
4
$this->template->wallPosts = $this->isAjax()
5
? array($this->wallposts->getOne($postId))
6
: $this->wallposts->getAllPosts();
7
8
$this->flashMessage($message);
9
$this->redrawControl('flashes');
10
$this->redrawControl('wallposts');
11
}
12
// F5 protection without JS
13
if (!$this->isAjax()){
14
$this->redirect('this');
15
}
16
}
17
18
public function handleEnablePost($postId)
19
{
20
$status = $this->wallposts->enablePost($postId);
21
$this->afterTogglePostStatus($status, $postId, 'Post enabled');
22
}
23
24
public function handleDisablePost($postId)
25
{
26
$status = $this->wallposts->disablePost($postId);
27
$this->afterTogglePostStatus($status, $postId, 'Post disabled');
28
}
Copied!

Drobnosti

Dobré je mít v repozitáři šablonu pro config.local.neon
Chtěl jsem oku lahodící výpis postů na homepage
A pomocí CSS animované zmizení flash message
tuto verzi najdete pod tagem :admin
Tím končí tento delší rozbor jak jsem připravoval aplikaci na zobrazování postů z Facebooku.

Díky

Last modified 1yr ago