Resources

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', 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::invoice.invoices'), '/settings/invoices', static::$icon)->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', Numeric::make('starting_balance', __('balance::fields.starting_balance')));
}

Additionally, you will need to ensure that the module has a migration that adds starting_balance database column to the companies table.

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 {}

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.

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 interfance.

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 will need to 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();
    }
}