Resources
- Introduction
- Defining Resources
- Registering Resources
- Resource Fields
- Resource Filters
- Resource Actions
- Resource Hooks
- Globally Searchable Resources
- Resource Table
- Frontend Components and Routes
- Importing Resources
- Exporting Resources
- FAQ
Introduction
The resources feature allows you to use more advanced Concord CRM features that are already pre-built and only need to be configured via the resource. For example, you can enable import functionality, allowing your module to have a table with custom views and filters, dynamic fields, etc.
Defining Resources
You can place the resource anywhere in the module app
folder, however, we do recommend defining your module related resources in the modules/[ModuleName]/app/Resources
.
namespace Modules\Invoices\Resources;
use Modules\Core\Contracts\Resources\WithResourceRoutes;
use Modules\Core\Resource\Resource;
use Modules\Invoices\Http\Resources\InvoiceResource;
class Invoice extends Resource implements WithResourceRoutes
{
/**
* The model the resource is related to.
*/
public static string $model = 'Modules\Invoices\Models\Invoice';
/**
* Get the json resource that should be used for json response.
*/
public function jsonResource(): string
{
return InvoiceResource::class;
}
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return __('invoices::invoice.invoices');
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return __('invoices::invoice.invoice');
}
}
To the resource related model, you will need to add the Modules\Core\Resource\Resourceable
trait.
namespace Modules\Invoices\Models;
use Modules\Core\Resource\Resourceable;
use Modules\Core\Models\Model;
class Invoice extends Model
{
use Resourceable
}
Resource Routes
When the resource implements the WithResourceRoutes
interface, Concord CRM will automatically recognize the resource when making HTTP requests to the following routes:
Verb | URI |
---|---|
GET | /{resourceName} |
POST | /{resourceName} |
GET | /{resourceName}/{id} |
PUT/PATCH | /{resourceName}/{id} |
DELETE | /{resourceName}/{id} |
The "resourceName" is plural name in lowercase of the resource class name, for example, Invoice resource will be invoices.
If for some reason you want to customize the auto generated name for the resource, you can override the name
method:
/**
* Get the resource name.
*/
public static function name(): string
{
return 'invoices';
}
Registering Menu Items
Your resource can register menu items when it's booted, to achieve this
Via the resource class, you can provide menu items for the sidebar and settings menu items that will be automatically registered when the resource is booted, this can be achieved by using the menu
and settingsMenu
methods of the resource.
use Modules\Core\Menu\MenuItem;
/**
* Provide the menu items for the resource.
*/
public function menu(): array
{
return [
MenuItem::make(static::label(), '/invoices')
->icon(static::$icon)
->position(5),
];
}
You will need to create the front-end route and component for this menu item, please refer to the general guide on how to add menu items.
If needed, you can register settings menu items as well, to achieve this use the settingsMenu
method:
use Modules\Core\Settings\SettingsMenuItem;
/**
* Provide the settings menu items for the resource.
*/
public function settingsMenu(): array
{
return [
SettingsMenuItem::make('invoices', __('invoices::invoice.invoices'))
->path('/invoices')
->icon('CurrencyDollar')
->order(23),
];
}
Sample applied for settings menu items, you will need to create the front-end route and component for this menu item, please refer to modules settings guide.
Registering Resources
The resource to be recognized by Concord CRM, will need to register it via the module service provider, to achieve this, in the module service provider, add the resource to the resources
property.
use Modules\Core\Support\ModuleServiceProvider;
class InvoicesServiceProvider extends ModuleServiceProvider
{
protected array $resources = [
\Modules\Invoices\Resources\Invoice::class,
];
}
Resource Fields
To define resource related fields, you should define the fields
method in the resource class:
use Modules\Core\Http\Requests\ResourceRequest;
use Modules\Core\Fields\Text;
use Modules\Core\Fields\CreatedAt;
use Modules\Core\Fields\UpdatedAt;
/**
* Provide the available resource fields.
*/
public function fields(ResourceRequest $request): array
{
return [
Text::make('title', __('invoices::invoice.title'))
->creationRules('required')
->updateRules('filled'),
CreatedAt::make()->hidden(),
UpdatedAt::make()->hidden(),
];
}
To see the available fields, via you code editor, explore the modules/Core/app/Fields
directory.
Registering Fields to Other Resources
Via the module service provider you can register fields to other resources, imagine you a building a "Balance" module and you want to include a starting balance field during company creation/update and detail view, using the module service provider setup
method register the field as shown below:
use Modules\Core\Facades\Fields;
use Modules\Core\Fields\Numeric;
/**
* Configure the module.
*/
protected function setup(): void
{
Fields::add(
'companies',
fn () => Numeric::make('starting_balance', __('balance::fields.starting_balance'))
);
}
Additionally, you will need to ensure to add module migration that adds starting_balance
database column to the companies
table.
To register multiple fields, the Closure
should return an array of fields instead of single field instance.
Resource Filters
To define resource filters, you will need to use the filters
method in the resource class, these filters will be used on the resource index view table.
use Modules\Core\Http\Requests\ResourceRequest;
use Modules\Core\Filters\Text as TextFilter;
/**
* Get the resource available filters.
*/
public function filters(ResourceRequest $request): array
{
return [
TextFilter::make('title', __('invoices::invoice.title'))->withoutNullOperators(),
];
}
To see the available filters, via your code editor, explore the modules/Core/app/Filters
directory.
Resource Actions
To define resource actions, you will need to use the actions
method in the resource class, these actions will be used by default on the resource index view table.
use Modules\Core\Http\Requests\ResourceRequest;
use Modules\Core\Http\Requests\ActionRequest;
use Modules\Core\Http\Models\Model;
/**
* Provides the resource available actions
*/
public function actions(ResourceRequest $request): array
{
return [
new \Modules\Core\Actions\BulkEditAction($this),
\Modules\Core\Actions\DeleteAction::make()->canRun(function (ActionRequest $request, Model $model, int $total) {
return $request->user()->can($total > 1 ? 'bulkDelete' : 'delete', $model);
})->showInline(),
];
}
To see the available actions, via your code editor, explore the modules/Core/app/Actions
directory.
Creating Actions
You can create actions based on the module specific requirements and attach them to the resource, for example in modules/Invoices/app/Actions
create new class MarkInvoicePaidAction.php
.
namespace Modules\Invoices\Actions;
use Illuminate\Support\Collection;
use Modules\Core\Actions\Action;
use Modules\Core\Actions\ActionFields;
use Modules\Core\Http\Requests\ActionRequest;
class MarkInvoicePaidAction extends Action
{
/**
* Handle the action execution.
*/
public function handle(Collection $models, ActionFields $fields): void
{
foreach($models as $invoice) {
$invoice->markAsPaid();
}
}
/**
* @param \Illumindate\Database\Eloquent\Model $model
*/
public function authorizedToRun(ActionRequest $request, $model): bool
{
return $request->user()->can('update', $model);
}
/**
* Action name.
*/
public function name(): string
{
return __('invoices::actions.mark_as_paid');
}
}
Then attach the action in the resource class:
use Modules\Core\Http\Requests\ResourceRequest;
/**
* Provides the resource available actions
*/
public function actions(ResourceRequest $request): array
{
return [
new \Modules\Invoices\Actions\MarkAsPaidAction,
// ...
];
}
Resource Hooks
Becauase Concord CRM already defines the routes for the resources common endpoints, if needed you can customize the create, update, delete process via the following resource hooks:
use Modules\Core\Http\Requests\ResourceRequest;
use Modules\Core\Models\Model;
/**
* Handle the "beforeCreate" resource record hook.
*/
public function beforeCreate(Model $model, ResourceRequest $request): void {}
/**
* Handle the "afterCreate" resource record hook.
*/
public function afterCreate(Model $model, ResourceRequest $request): void {}
/**
* Handle the "beforeUpdate" resource record hook.
*/
public function beforeUpdate(Model $model, ResourceRequest $request): void {}
/**
* Handle the "afterUpdate" resource record hook.
*/
public function afterUpdate(Model $model, ResourceRequest $request): void {}
/**
* Handle the "beforeDelete" resource record hook.
*/
public function beforeDelete(Model $model, ResourceRequest $request): void {}
/**
* Handle the "afterDelete" resource record hook.
*/
public function afterDelete(Model $model, ResourceRequest $request): void {}
/**
* Handle the "beforeRestore" resource record hook.
*/
public function beforeRestore(Model $model, ResourceRequest $request): void {}
/**
* Handle the "afterRestore" resource record hook.
*/
public function afterRestore(Model $model, ResourceRequest $request): void {}
Globally Searchable Resources
To make the resource globally searchable, you will need to enable global search for the resource by setting the globallySearchable
property value to true
.
/**
* Indicates whether the resource is globally searchable.
*/
public static bool $globallySearchable = true;
Global Search Title
To customize the title that will be used to display the result in the global search you can use the title
attribute of the resource class:
/**
* The attribute to be used when the resource should be displayed.
*/
public static string $title = 'title';
Alternatively you can fully override the getGlobalSearchTitle
method:
use Modules\Core\Models\Model;
/**
* Get the global search title.
*/
public function globalSearchTitle(Model $model): string
{
return $model->custom_title_accessor;
}
Global Search Columns
Concord CRM automatically determines the globally searchable columns based on the resource defined fields, however, if you need to optimize the query or need to provide columns that will be used for global search, for example, if the resource does not have any fields defined, you will need to override the globalSearchColumns
method of the resource:
/**
* Get columns that should be used for global search.
*/
public function globalSearchColumns(): array
{
return ['title' => 'like', 'number' => '='];
}
Resource Table
If you need to display a resource table, you will need to implement the Modules\Core\Contracts\Resource\Tableable
interface to the resource.
namespace Modules\Invoices\Resources;
use Modules\Core\Http\Requests\ResourceRequest;
use Modules\Core\Contracts\Resources\Tableable;
use Modules\Core\Contracts\Resources\WithResourceRoutes;
use Modules\Core\Resource\Resource;
use Illuminate\Database\Eloquent\Builder;
class Invoice extends Resource implements WithResourceRoutes, Tableable
{
/**
* Provide the resource table class instance.
*/
public function table(Builder $query, ResourceRequest $request, string $identifier): Table
{
return Table::make($query, $request, $identifier)
->withViews()
->withDefaultView('invoices::invoice.views.all')
->orderBy('created_at', 'desc');
}
}
Concord CRM will automatically use the defined fields, actions and filters from the resource to create the table.
Table Default Views
When the module being created is activated and the customer visit the index page for the first time, Concord CRM may create default views for the table that the customer will be able to use without any effort.
To create default views, you should use Table
instance withDefaultView
method.
use Modules\Core\Filters\FilterChildGroup;
use Modules\Core\Filters\FilterGroups;
/**
* Provide the resource table class instance.
*/
public function table(Builder $query, ResourceRequest $request, string $identifier): Table
{
return Table::make($query, $request, $identifier)
->withViews()
->withDefaultView(
name: 'invoices::views.expired',
flag: 'expired-invoices',
rules: new FilterGroups([
new FilterChildGroup(rules: [
DateTimeFilter::make('date')->setOperator('is')->setValue('past'),
]),
])
);
}
Frontend Components and Routes
Concord CRM does not automatically register and create routes for the front-end whenever a resource is registered. Each resource may have different requirements, logic, and UI organization, so you will need to build these components yourself using common components provided by Concord CRM.
For example, you should create the resource index, view, create, and update components on your own.
For each resource you create, if suitable, you should define the routes and components for the front-end.
It's important to note that Concord CRM is not an admin panel or administrator tool like Laravel Nova or Filament. Instead, it provides a flexible framework that requires you to build and customize the components and routes according to your specific needs.
// modules/[ModuleName]/resources/app.js
import { translate } from "core/i18n";
import InvoicesIndex from "./views/InvoicesIndex.vue";
import InvoicesCreate from "./views/InvoicesCreate.vue";
import InvoicesShow from "./views/InvoicesShow.vue";
if (window.Innoclapps) {
Innoclapps.booting(function (app, router) {
router.addRoute({
path: "/invoices",
component: InvoicesIndex,
meta: {
title: translate("modulename::group.key"),
},
});
router.addRoute({
path: "/invoices/:id",
component: InvoicesShow,
meta: {
title: translate("modulename::group.key"),
},
});
router.addRoute({
path: "/invoices/create",
component: InvoicesCreate,
meta: {
title: translate("modulename::group.key"),
},
});
});
}
Here are examples of the corresponding components:
<!-- InvoicesIndex.vue -->
<template>
<MainLayout>
<template #actions>
<NavbarSeparator class="hidden lg:block" />
<NavbarItems>
<IButton
v-show="!tableEmpty"
variant="primary"
icon="PlusSolid"
:disabled="!resourceInformation.authorizedToCreate"
to="/invoices/create"
:text="$t('core::resource.create', { resource: resourceInformation.singularLabel })"
/>
</NavbarItems>
</template>
<IOverlay v-if="!tableLoaded" show />
<div v-if="shouldShowEmptyState" class="m-auto mt-8 max-w-5xl">
<IEmptyState
v-bind="{
to: '/invoices/create',
title: $t('invoices::invoice.empty_state.title'),
buttonText: $t('invoices::invoice.create'),
description: $t('invoices::invoice.empty_state.description'),
}"
/>
</div>
<div v-show="!tableEmpty">
<ResourceTable
:resource-name="resourceName"
@loaded="handleTableLoaded"
/>
</div>
</MainLayout>
</template>
<script setup>
import { computed, ref } from "vue";
import { useTable } from "@/Core/composables/useTable";
const resourceName = "invoices";
const resourceInformation = Innoclapps.resource(resourceName);
const { reloadTable } = useTable(resourceName);
const tableEmpty = ref(true);
const tableLoaded = ref(false);
const shouldShowEmptyState = computed(
() => tableEmpty.value && tableLoaded.value
);
function handleTableLoaded(e) {
tableEmpty.value = e.isPreEmpty;
tableLoaded.value = true;
}
function refreshIndex() {
reloadTable();
}
</script>
<!-- InvoicesCreate.vue -->
<template>
<MainLayout>
<ICard>
<ICardBody>
<form @submit.prevent="create">
<FieldsPlaceholder v-if="!hasFields" />
<FormFields
:fields="fields"
:form="form"
:resource-name="resourceName"
focus-first
@update-field-value="form.fill($event.attribute, $event.value)"
@set-initial-value="form.set($event.attribute, $event.value)"
/>
<IButton
type="submit"
:disabled="form.busy"
:loading="form.busy"
:text="$t('core::app.create')"
/>
</form>
</ICardBody>
</ICard>
</MainLayout>
</template>
<script setup>
import { ref } from "vue";
import { useRouter } from "core/router";
import { useForm } from "@/Core/composables/useForm";
import { useResourceable } from "@/Core/composables/useResourceable";
import { useResourceFields } from "@/Core/composables/useResourceFields";
const resourceName = "invoices";
const { t } = useI18n();
const router = useRouter();
const { fields, hasFields, getCreateFields } = useResourceFields();
const { form } = useForm();
const { createResource } = useResourceable(resourceName);
async function create() {
try {
let invoice = await createResource(form);
Innoclapps.success(t("invoices::invoice.created"));
router.push(`/invoices/${invoice.id}`);
} catch (e) {
if (e.isValidationError()) {
Innoclapps.error(t("core::app.form_validation_failed"), 3000);
}
return Promise.reject(e);
}
}
async function prepareComponent() {
const createFields = await getCreateFields(resourceName);
fields.value = createFields;
}
prepareComponent();
</script>
<!-- InvoicesShow.vue -->
<template>
<MainLayout> {{ resource.title }} </MainLayout>
</template>
<script setup>
import { computed, ref } from "vue";
import { useRoute } from "core/router";
import { usePageTitle } from "@/Core/composables/usePageTitle";
import { useResource } from "@/Core/composables/useResource";
const resourceName = "invoices";
const route = useRoute();
const invoiceId = computed(() => route.params.id);
const {
resourceInformation,
resource,
synchronizeResource,
detachResourceAssociations,
incrementResourceCount,
decrementResourceCount,
fetchResource,
updateResource,
resourceReady: componentReady,
} = useResource(resourceName, invoiceId);
usePageTitle(computed(() => resource.value.title));
fetchResource();
</script>
Importing Resources
To add support for resource import based on the resource defined fields, you should add the Modules\Core\Contracts\Resources\Importable
interfance.
namespace Modules\Invoices\Resources;
use Modules\Core\Contracts\Resources\Importable;
use Modules\Core\Resource\Resource;
class Invoice extends Resource implements Importable
{
}
Make sure that you are running Vite dev server with npm run dev
and navigate to /import/invoices
, where the second URL param is the resource name.
Anywhere in the module components, you can add the /import/invoices
link, for example in a dropdown or as a button and give the ability the user to navigate easily to the import view.
The /import/[RESOURCE_NAME]
is already existing route defined from Concord CRM, you don't need to define any front-end routes for import.
Exporting Resources
To add support for resource export based on the resource defined fields, you should add the Modules\Core\Contracts\Resources\Exportable
interface.
namespace Modules\Invoices\Resources;
use Modules\Core\Contracts\Resources\Exportable;
use Modules\Core\Resource\Resource;
class Invoice extends Resource implements Exportable
{
}
Concord CRM already has a front-end component for performing export, usually in the index component of your resource, you will need to add the component that will be used for exporting.
This component must be opened as a modal.
<!-- InvoicesIndex.vue -->
<template>
<MainLayout>
<IButton @click="$dialog.show('invoices-export-modal')">
{{ $t('invoices::invoice.export') }}
</IButton>
<!-- "ResourceExport" is a global component. -->
<ResourceExport
resource-name="invoices"
modal-id="invoices-export-modal"
/>
</MainLayout>
</template>
<script setup>
// ...
</script>
FAQ
Is my module required to create resources?
No, it is not required. However, if you want to use built-in features from Concord CRM, such as ready tables with actions and filters, exporting, importing, etc., you should create a resource. Otherwise, you will need to build those features separately.
Can i create a resource only to get the benefits of using the advanced table?
Sure, some of the Concord CRM resources already using this approach, here is quick example:
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Fields\FieldsCollection;
use Modules\Core\Table\Column;
use Modules\Core\Fields\Text;
use Modules\Core\Fields\CreatedAt;
use Modules\Core\Fields\UpdatedAt;
use Modules\Core\Http\Requests\ResourceRequest;
use Modules\Core\Table\Table;
class Invoice extends Resource implements Tableable
{
/**
* Provide the resource table class instance.
*/
public function table(Builder $query, ResourceRequest $request, string $identifier): Table
{
return Table::make($query, $request, $identifier)
->withViews()
->singleView()
->orderBy(static::$orderBy, static::$orderByDir);
}
/**
* Get the fields for index.
*/
public function fieldsForIndex(): FieldsCollection
{
return (new FieldsCollection([
Text::make('title', __('invoices::invoice.title'))
->tapIndexColumn(fn (Column $column) => $column
->width('300px')
->route('/invoices/{id}/edit')
->primary()),
CreatedAt::make()->hidden(),
UpdatedAt::make()->hidden(),
]))->disableInlineEdit();
}
}