ساخت داشبورد سازمانی مقیاس پذیر با انگولار — بخش دوم

۷۳ بازدید
آخرین به‌روزرسانی: ۱۱ شهریور ۱۴۰۲
زمان مطالعه: ۹ دقیقه
ساخت داشبورد سازمانی مقیاس پذیر با انگولار — بخش دوم

در بخش قبلی به بررسی بهره‌برداری از مزیت رندرینگ کامپوننت دینامیک برای جداسازی محتوای داشبورد از خود داشبورد پرداختیم. در این بخش به بررسی شیوه حذف/اضافه آیتم‌ها از track-ها، شیوه ایجاد فراخوانی‌های سرویس برای کارت‌های منفرد روی داشبورد و شیوه پیاده‌سازی قابلیت drag and drop در عین حفظ حالت در داشبورد سازمانی مقیاس پذیر خواهیم پرداخت. برای مطالعه بخش قبلی روی لینک زیر کلیک کنید:

997696

حذف/اضافه آیتم‌ها

پیش از آغاز این بخش باید اشاره کنیم که بهترین رویه در انگولار این است که «حالت مشترک» (Shared State) را به داخل سرویس‌هایی که می‌توانند آن را مصرف کنند روی کامپوننت‌های مختلف اپلیکیشن منتقل کنیم. از آنجا که track-های خود را در dashboard.component.ts اعلان کرده‌ایم به صورت مستقیم در دسترسی هیچ کامپوننت دیگری قرار ندارد و این شامل محتوای خود داشبورد نیز می‌شود. برای ایجاد امکان حذف و اضافه یک کارت از هر کجا به جز خود کامپوننت داشبورد، باید متغیر tracks را به یک سرویس انتقال دهیم.

پیش از ادامه باید اشاره کنیم که درک مفهوم سرویس‌های observable بسیار حائز اهمیت است. سرویس‌های observable سرویس‌های معمولی هستند که داده و عموماً حالت را به شکل observable بازگشت می‌دهند. اگر به تازگی با مفهوم سرویس‌های observable آشنا شده‌اید و مزایا و معایب آن‌ها را به خوبی نمی‌دانید، پیشنهاد می‌کنیم به مطالعه این مقاله (+) بپردازید. فعلاً این امکان به ما اجازه می‌دهد که حالت داشبورد را به اشتراک بگذاریم.

ابتدا یک سرویس داشبورد ایجاد می‌کنیم. سپس آرایه tracks را از dashboard.component.ts به سرویس کپی می‌کنیم. همچنین نام متغیر را به defaultState تغییر می‌دهیم.

فایل dashboard.service.ts

1import { Injectable } from '@angular/core';
2
3import { Track } from './models/track';
4import { DashboardCards } from './dashboard-cards.enum';
5
6@Injectable({
7  providedIn: 'root',
8})
9export class DashboardService {
10  defaultState: Array<Track> = [
11      {
12        items: [
13          {
14            component: DashboardCards.HELLO_WORLD,
15            id: 'hello-world',
16          },
17        ],
18      },
19      {
20        items: [
21          {
22            component: DashboardCards.HELLO_WORLD_TWO,
23            id: 'hello-world-2',
24          },
25        ],
26      },
27  }

در ادامه یک subject ایجاد می‌کنیم که می‌توانیم داده‌ها را به آن ارسال کنیم. منظور از subject چیزی مانند یک EventEmitter با امکان داشتن چندین observers است. در این مورد می‌خواهیم از یک BehaviorSubject استفاده کنیم. یک BehaviorSubject آخرین مقدار مفروض را به شنونده‌های جدید ارسال می‌کند. می‌توانیم با فراخوانی ()this.subject.asObservable یک observable را از subject بسازیم که نام متغیر آن را tracks$ گذاشته‌ایم.

فایل dashboard.service.ts

1import { Injectable } from '@angular/core';
2
3import { BehaviorSubject } from 'rxjs';
4
5import { Track } from './models/track';
6import { DashboardCards } from './dashboard-cards.enum';
7
8@Injectable({
9...
10})
11
12export class DashboardService {
13  private subject = new BehaviorSubject<Track[]>(this.defaultState);
14  tracks$ = this.subject.asObservable();
15  ...
16}

در نهایت tracks را از سرویس در کامپوننت داشبورد بازیابی می‌کنیم.

فایل dashboard.component.ts

1@Component({
23})
4export class DashboardComponent implements OnInit, AfterViewInit {
5tracks: Array<Track> = [];
6
7constructor(
8  private dashboardService: DashboardService
910  ) {}
11
12  ngOnInit() {
13    this.dashboardService.tracks$
14      .pipe(
15        tap(tracks => (this.tracks = tracks))
16         /* Make sure to unsubscribe! */
17        )
18        .subscribe();
19      }
2021  }

اینک می‌توانیم شروع به حذف و اضافه track-ها از داشبورد بکنیم.

حذف آیتم‌ها

برای حذف آیتم‌ها یک متد روی سرویس داشبورد ایجاد می‌کنیم و نام آن را removeItem می‌گذاریم. متد removeItem یک آیتم را به عنوان پارامتر می‌گیرد و تلاش می‌کند تا آیتم را از حالت tracks حذف کرده و تغییرات را به subject ارسال کند.

برای دریافت حالت از subject متد ()subject.getValue را فراخوانی می‌کنیم. بدین ترتیب یک اسنپ‌شات از ()subject.getValue در آن وهله زمانی به دست می‌آید. اینک که یک اسنپ‌شات داریم می‌توانیم شروع به دستکاری آن بکنیم.

فایل dashboard.service.ts

1removeItem = (item: Item) => {
2  const state = this.subject.getValue();
3});

با تعریف یک حلقه روی هر track در آرایه حالت می‌توان آیتم را حذف کرد. سپس برای هر track روی آیتم‌های موجود در آن حلقه‌ای تعریف می‌کنیم. اگر آیتم در حلقه با آیتم ارائه شده به عنوان پارامتر مطابقت یابد آن را از ترک برمی‌داریم. زمانی که کار انجام یافت، حالت جدید را بار دیگر با فراخوانی this.subject.next()‎ به subject ارسال می‌کنیم.

فایل dashboard.service.ts

1removeItem = (item: Item) => {
2  const state = this.subject.getValue();
3  state.forEach(track => {
4    track.items.forEach((i, index) => {
5      if (i === item) {
6        track.items.splice(index, 1);
7      }
8    });
9  });
10  this.subject.next(state);
11};

افزودن آیتم‌ها

متدی به نام addItem ایجاد می‌کنیم که یک آیتم را گرفته و آن را به یکی از track-ها اضافه می‌کند. آیتم جدید را با آخرین مقدار آیتم‌ها به track ارسال می‌کنیم. همچنین می‌توانید به صورت اختیاری پیش از افزودن آیتم بررسی کنید که آیتم روی هیچ کدام از track-ها نباشد تا از بروز موارد تکراری جلوگیری کنید.

فایل dashboard.service.ts

1  addItem = (item: Item) => {
2    const state = this.subject.getValue();
3
4    if (state[0].items.indexOf(item) !== -1 || state[1].items.indexOf(item) !== -1) {
5      console.warn('Item with the same id exists on the dashboard.');
6      return;
7    }
8
9    state[0].items.length <= state[1].items.length ? state[0].items.push(item) : state[1].items.push(item);
10
11    this.subject.next(state);
12  };

پیش از آن که بتوانیم از متدهای حذف/اضافه آیتم‌ها استفاده کنیم، باید با شیوه ایجاد فراخوانی‌های سرویس منفرد از محتواهای داشبورد آشنا شویم. در بخش قبلی این موضوع را توضیح خواهیم داد.

فراخوانی سرویس‌ها و ارسال حالت به درون محتوای داشبورد

جداسازی حالت محتوا از داشبورد بسیار مفید است. بدین ترتیب امکان ایجاد مستقل محتوای داشبورد پدید می‌آید که به صورت کارت‌هایی بدون این که منطق محتوایی خاصی روی کامپوننت وجود داشته باشد مشاهده می‌شود. اگر مشغول کار روی یک محیط سازمانی باشید، جدا کردن داشبورد از حالت محتوا امکان داشتن چندین محیط مختلف برای مدیریت، و نگهداری محتوای داشبورد فراهم می‌آید که نیازی به دستکاری خود داشبورد هم ندارد. برای نمونه بخش مالی سازمان ممکن است بخواهد یک کارت ملی برای نمایش موارد مالی ایجاد کند. در زمان انجام این کار لازم نیست روی هیچ چیز به جز cards.enum.ts و dashboard-cards.ts کار کنند.

در این مثال سرویس دیگری به نام HelloService نیز می‌سازیم. این سرویس قرار است لیستی از نام‌ها را نگهداری کند که به وسیله محتوای داشبورد قابل بازیابی است. ضمناً یک نام Input() name هم روی HelloWorldComponent اضافه می‌کند و آن را در قالب کامپوننت نمایش می‌دهد.

فایل hello-world.service.ts

1import { Injectable } from '@angular/core';
2
3@Injectable({
4  providedIn: 'root',
5})
6export class HelloService {
7  names = ['Landon', 'Tim'];
8  
9  constructor() {}
10}

فایل hello-world.component.ts

1import { Component, OnInit, Input } from '@angular/core';
2
3@Component({
4  selector: 'app-hello-world',
5  templateUrl: './hello-world.component.html',
6  styleUrls: ['./hello-world.component.scss'],
7})
8export class HelloWorldComponent implements OnInit {
9  @Input() name;
10
11  constructor() {}
12
13  ngOnInit() {}
14}

فایل hello-world.component.html

1<p>hello-world {{ name || 'works' }}!</p>

اگر در این راهنما با ما همراه بوده باشید، در بخش قبلی یک کامپوننت HelloWorldContainer ایجاد کرده‌اید. این کانتینر باید سرویس را فراخوانی کند، اطلاعاتی را که نیاز دارد بازیابی نماید و آن را از طریق مشخصه‌های ()input کامپوننت فرزند به محتوا ارسال کند.

به دلیل روش خاص نگاشت کانتینرها به آیتم‌های track باید کانتینری برای هر آیتم یکتا روی داشبورد وجود داشته باشد. دلیل این امر آن است که کانتینرها باید سرویس‌هایی را برای بازیابی داده‌ها داشته باشند و بسته به این که کامپوننت‌ها چگونه سازماندهی شده‌اند، این امر می‌تواند موجب محدودیت قابلیت استفاده مجدد کانتینرهای خاص بشود.

برای بازیابی داده‌های مختلف باید یک کانتینر ثانویه به نام hello-world-two.container.ts ایجاد کرده و آن را به entryComponents اضافه کنیم که همان فرایندی است که در مورد hello-world.container.ts انجام دادیم. هر کانتینر قرار است نام را از HelloService بازیابی کرده و آن را به HelloWorldComponent ارسال کند. تنها تفاوت در این است که نام هر کانتینری که بازیابی می‌شود متفاوت است.

فایل‌های hello-world.container.ts و hello-world-two.container.ts

1import { Component } from '@angular/core';
2
3import { HelloService } from '../hello.service';
4import { DashboardCardContainer } from '../dashboard/dashboard-card/dashboard-card.container';
5
6@Component({
7  template: `
8    <app-hello-world [name]="name"></app-hello-world>
9  `,
10})
11export class HelloWorldContainer extends DashboardCardContainer {
12  name;
13
14  constructor(private helloWorldService: HelloService) {
15    super();
16    this.name = helloWorldService.names[0];
17  }
18}

اکنون به dashboard-cards.ts و dashboard-cards.enum.ts می‌رویم و مدخل HELLO_WORLD_TWO دیگری اضافه می‌کنیم.

فایل dashboard-cards.ts

1import { HelloWorldContainer } from '../hello-world/hello-world.container';
2import { HelloWorldTwoContainer } from '../hello-world/hello-world-two.container';
3
4export const dashboardCards = {
5  HELLO_WORLD: HelloWorldContainer,
6  HELLO_WORLD_TWO: HelloWorldTwoContainer,
7};

فایل dashboards-cards.enum.ts

1export enum DashboardCards {
2  HELLO_WORLD = 'HELLO_WORLD',
3  HELLO_WORLD_TWO = 'HELLO_WORLD_TWO',
4}

در فایل dashboard.service.ts کامپوننت آیتم دوم را به صورت HELLO_WORLD_TWO تغییر می‌دهیم. تغییرات را ذخیره کنید تا ببینید که اینک دو کارت با محتوای متفاوت روی داشبورد قرار دارند:

داشبورد سازمانی مقیاس پذیر

اکنون یک داشبورد استاتیک با محتوای مستقل ساخته‌ایم. در بخش بعدی کاری می‌کنیم که محتوا قابلیت کشیدن و رها کردن پیدا کند.

پیاده‌سازی قابلیت «کشیدن و رها کردن»

قابلیت کشیدن و رها کردن (Drag and Drop) یک وظیفه کاملاً دشوار و خسته‌کننده است. خوشبختانه کتابخانه ng2-dragula اجرای آن را به شدت آسان کرده است. این کتابخانه را با دستور زیر نصب کنید:

yarn add ng2-dragula

در ریپوی dragula (+) لیستی از دستورالعمل‌های نصب می‌بینید که باید پیروی کنید. dragula همچنین باید در ماژول داشبورد ایمپورت شود.

برای پیاده‌سازی قابلیت کشیدن و رها کردن باید دایرکتیوهای dragula و dragulaModel را همراه با رویداد dragulaModelChanged اضافه کنیم. دایرکتیو dragula گرهی که track مفروض باید به آن تعلق یابد را مشخص می‌سازد. در این مثال، هر دو مورد به گروه dashboard تعلق دارند. دایرکتیو dragulaModel تعیین می‌کند که track به نام dragula باید شامل کدام آیتم‌ها باشد. این حالت با داده‌های track ما همگام‌سازی می‌شود. همچنین مقداری کد CSS وجود دارد که تضمین می‌کند وقتی track خالی است اندازه آن به صفر کاهش نمی‌یابد و از این رو می‌توانیم دوباره روی آن چیزی را بکشیم.

فایل dashboard.component.html

1<div class="board">
2  <div *ngFor="let track of tracks; let i = index">
3    <div dragula="dashboard" [dragulaModel]="track.items" (dragulaModelChange)="changed($event, i)" class="tracks">
4      <div *ngFor="let item of track.items">
5        <mat-card style="margin: 1rem;">
6          <ng-template appDashboardOutlet [item]="item"></ng-template>
7        </mat-card>
8      </div>
9    </div>
10  </div>
11</div>

فایل dashboard.component.scss

1.tracks {
2  min-width: 200px;
3  min-height: 200px;
4}

با افزودن موارد فوق، کارت‌ها اینک قابلیت کشیده شدن یافته‌اند. اینک باید به ذخیره‌سازی حالت بپردازیم.

ذخیره‌سازی حالت

برای حفظ تغییرات در زمان کشیدن و رها کردن باید چند کار انجام دهیم. ابتدا باید حالت تغییر یافته را که از سوی رویداد dragulaModelChanged را بگیریم و آن را در سرویس تنظیم کنیم. به این منظور به یک متد نیاز داریم که کل حالت track-ها را گرفته و آن‌ها را به subject ارسال کند. یک متد به نام setState روی DashboardService اضافه می‌کنیم. setState نیازمند آرایه‌ای از track-ها است و بر این اساس حالت را تنظیم می‌کند.

فایل dashboard.service.ts

1setState = (tracks: Array<Track>) => {
2  this.subject.next(tracks);
3};

ما آن را در کامپوننت داشبورد زمانی که track-ها تغییر یافتند فراخوانی می‌کنیم.

به این ترتیب dragulaModelChanged یک رویداد ارسال می‌کند که آرایه‌ای بازگشت می‌دهد و این آرایه به همراه تغییرات مناسب پس از کشیدن و رها کردن به dragulaModel ارسال می‌شود. نکته کار در این جا است که این متد تنها آیتم‌های منفرد یک track خاص را بازگشت می‌دهد و نه آرایه‌ای از track-ها. این بدان معنی است که باید بدانیم کدام آیتم‌های track تغییر یافته‌اند. به این منظور می‌توانیم یک ارجاع به اندیس روی حلقه ngFor مربوط به track-ها در قالب داشبورد اضافه کنیم. بدین ترتیب ارجاعی به آن که تغییر یافته خواهیم داشت که dragulaModelChanged به آن اشاره می‌کند.

متدی به نام changed روی کامپوننت داشبورد ایجاد می‌کنیم. متد changed یک track و یک trackIndex می‌پذیرد. زمانی که متد changed فراخوانی شود، حالت کنونی را بازیابی کرده و تغییرات ارائه شده از سوی رویداد را برای ما اعمال می‌کند. زمانی که ارجاع به حالت تغییر یافته به دست ما برسد، آن را به setState ارسال می‌کنیم تا سرویس را به‌روزرسانی کند.

فایل dashboard.component.ts

1changed = (items: Array<Item>, trackIndex: number) => {
2  const state = this.tracks;
3  state[trackIndex].items = items as Array<Item>;
4  this.dashboardService.setState(state);
5};

اینک می‌توانیم رویداد dragulaModelChanged را به div خود اضافه کرده و changed را فراخوانی نماییم.

فایل dashboard.component.html

1<div class="board">
2  <div *ngFor="let track of tracks; let i = index">
3    <div dragula="dashboard" [dragulaModel]="track.items" (dragulaModelChange)="changed($event, i)" class="tracks">
4      <div *ngFor="let item of track.items">
5        <mat-card style="margin: 1rem;">
6          <ng-template appDashboardOutlet [item]="item"></ng-template>
7        </mat-card>
8      </div>
9    </div>
10  </div>
11</div>

اینک به جایی رسیدیم که همه چیز پیچیده می‌شود. ما شیئی را که تعیین می‌کند چه چیزهایی باید رندر شوند به‌روزرسانی کرده‌ایم و باید به بخش تشخیص تغییر بگوییم که تغییرات را بررسی کند و تنها آن موقع است که محتوا را بارگذاری می‌کنیم. اگر این کارها به ترتیب صحیحی انجام نشوند، متد loadContents مجموعه صحیحی از ng-templates برای رندر کردن کامپوننت‌ها بر مبنای آن نخواهد داشت. بهترین مکان برای افزودن فراخوانی در اشتراک tracks است. این اشتراک هر زمان که tracks در سرویس داشبورد یک مقدار جدید گیرد فراخوانی می شود. در مورد مثال ما این زمانی است که کارت‌ها کشیده و رها می‌شوند.

فایل dashboard.component.ts

1  ngOnInit() {
2    this.dashboardService.tracks$
3      .pipe(
4        tap(tracks => (this.tracks = tracks))
5        /* Make sure to unsubscribe! */
6      )
7      .subscribe(() => {
8        this.cd.detectChanges();
9        this.loadContents();
10      });
11  }

چنان که می‌بینید حالت در زمان کشیده شدن نیز سازگاری خود را حفظ می‌کند. اینک تنها چیزی که باقی مانده است، ذخیره کردن حالت است.

ذخیره‌سازی حالت داشبورد

ذخیره‌سازی حالت داشبورد کاملاً ساده است. تنها کاری که باید انجام دهیم ذخیره‌سازی track–های داشبورد در storage لوکال (یا هر جای دیگری که دوست داریم) و بارگذاری آن در زمان آغاز به کار است. همچنین باید در موردی که کاربران نخستین بار داشبورد را باز می‌کنند یک پیکربندی پیش‌فرض را پیاده‌سازی کنیم.

یک متد روی سرویس داشبورد به نام loadFromLocalStorage ایجاد می‌کنیم. این متد حالت داشبورد را می‌گیرد و سرویس را به‌روزرسانی می‌کند.

فایل dashboard.service.ts

1  loadTracksFromStorage = () => {
2    const tracks = JSON.parse(localStorage.getItem('tracks') as string);
3    if (tracks) {
4      this.subject.next(tracks);
5    }
6  };

متد دیگری ایجاد به نام saveTracksToStorage می‌کنیم. این متد چنان که از نامش مشخص است، tracks را به صورت یک رشته در localstorage ذخیره می‌کند.

فایل dashboard.service.ts

1  saveTracksToStorage = () => {
2    const state = this.subject.getValue();
3    localStorage.setItem('tracks', JSON.stringify(state));
4  };
5}

برای ذخیره‌سازی به‌روزرسانی‌های بعدی در tracks باید در tracks$ در سازنده سرویس داشبورد مشترک شویم. البته پیش از آن باید مطمئن شویم که چه چیز را فراخوانی می‌کنیم تا جدیدترین حالت ذخیره شده را به دست آوریم.

فایل dashboard.service.ts

1constructor() {
2  ...
3  this.loadTracksFromStorage();
4  this.tracks$.subscribe(() => {
5    this.saveTracksToStorage();
6});
7)

نتیجه نهایی به صورت زیر است:

داشبورد سازمانی مقیاس پذیر

اینک یک داشبورد دینامیک با قابلیت کشیدن و رها کردن داریم که حالت خود را به طور کاملا مستقل از محتوا حفظ می‌کند. برای مشاهده کد منبع به این صفحه (+) مراجعه کنید.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۰ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
itnext
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *