Browse Source

feat: enhance `SystemSettingKey` and refactor Alpine plugins

- Add `CURRENT_TERM` to `SystemSettingKey` with dynamic selection using `SelectTermQuery`.
- Refactor Alpine plugins (`chart`, `quill`, `select2`) to improve configurability, compatibility with Livewire, and dynamic updates.
- Clean up Blade views for streamlined script injection.
master
Syarif Ubaidillah 3 months ago
parent
commit
33da380c20
  1. 11
      app/Domains/System/Enums/SystemSettingKey.php
  2. 215
      resources/js/alpine/chart.js
  3. 104
      resources/js/alpine/quill.js
  4. 36
      resources/js/alpine/select2.js
  5. 40
      resources/js/alpinejs.js
  6. 4
      resources/views/pages/finance/invoice/⚡form-modal/form-modal.blade.php
  7. 3
      resources/views/pages/finance/product-mapping/⚡form-modal/form-modal.blade.php
  8. 2
      resources/views/pages/identity/users/index.blade.php
  9. 4
      resources/views/pages/identity/users/⚡role-selection-modal/role-selection-modal.blade.php
  10. 77
      resources/views/widgets/charts/⚡role-distribution.blade.php
  11. 52
      resources/views/widgets/charts/⚡user-growth.blade.php

11
app/Domains/System/Enums/SystemSettingKey.php

@ -2,6 +2,7 @@
namespace App\Domains\System\Enums; namespace App\Domains\System\Enums;
use App\Domains\Academic\Queries\Selection\SelectTermQuery;
use App\Domains\System\Traits\Enum\HasPredicateMethod; use App\Domains\System\Traits\Enum\HasPredicateMethod;
use App\UI\Enums\Concerns\InteractsWithLabels; use App\UI\Enums\Concerns\InteractsWithLabels;
use App\UI\Enums\Contracts\HasLabel; use App\UI\Enums\Contracts\HasLabel;
@ -25,6 +26,7 @@ enum SystemSettingKey: string implements HasLabel, HasSchema
case WEB_ADDRESS = 'web-address'; case WEB_ADDRESS = 'web-address';
case DEFAULT_LANGUAGE = 'default_language'; case DEFAULT_LANGUAGE = 'default_language';
case TIMEZONE = 'timezone'; case TIMEZONE = 'timezone';
case CURRENT_TERM = 'current_term';
case GOOGLE_TAG_MANAGER_ID = 'google-tag_manager_id'; case GOOGLE_TAG_MANAGER_ID = 'google-tag_manager_id';
case GOOGLE_WEBMASTER_ID = 'google-webmaster_id'; case GOOGLE_WEBMASTER_ID = 'google-webmaster_id';
@ -84,6 +86,13 @@ enum SystemSettingKey: string implements HasLabel, HasSchema
'id' => __('domains/system/enum.system_setting_key_options.default_language.id'), 'id' => __('domains/system/enum.system_setting_key_options.default_language.id'),
]), ]),
self::CURRENT_TERM => InputSchema::make()
->type(InputType::SELECT)
->default('en')
->label($this->label())
->select2()
->options(fn() => SelectTermQuery::fetch()),
self::TIMEZONE => InputSchema::make() self::TIMEZONE => InputSchema::make()
->type(InputType::SELECT) ->type(InputType::SELECT)
->default('UTC') ->default('UTC')
@ -112,7 +121,7 @@ enum SystemSettingKey: string implements HasLabel, HasSchema
], ],
], ],
[ [
__('domains/system/pages.settings.sections.general') => [self::DEFAULT_LANGUAGE, self::TIMEZONE], __('domains/system/pages.settings.sections.general') => [self::DEFAULT_LANGUAGE, self::TIMEZONE, self::CURRENT_TERM],
__('domains/system/pages.settings.sections.webmaster') => [self::GOOGLE_TAG_MANAGER_ID, self::GOOGLE_WEBMASTER_ID], __('domains/system/pages.settings.sections.webmaster') => [self::GOOGLE_TAG_MANAGER_ID, self::GOOGLE_WEBMASTER_ID],
], ],
]; ];

215
resources/js/alpine/chart.js

@ -1,25 +1,212 @@
import '../plugin/apexchart.js'
export default function AlpineChart(Alpine) { export default function AlpineChart(Alpine) {
Alpine.directive('chart', function (el, { expression }, { evaluate, cleanup }) { Alpine.directive(
let config = evaluate(expression); "chart",
function (el, { expression }, { evaluateLater, cleanup }) {
const getContent = evaluateLater(expression);
const chart = new window.ApexCharts(el, config); import("apexcharts").then(({ default: ApexCharts }) => {
chart.render(); getContent((userConfig) => {
if (!userConfig || typeof userConfig !== "object") return;
// 1. Fetch live computed CSS variables directly from :root (document.documentElement)
const getCoreUIColors = () => {
const style = getComputedStyle(document.body);
// CoreUI v4/v5 & Bootstrap 5 theme variable names
const bodyColor =
style.getPropertyValue("--cui-body-color").trim() ||
style.getPropertyValue("--bs-body-color").trim() ||
"#a6b0cf";
const secondaryColor =
style
.getPropertyValue("--cui-secondary-color")
.trim() ||
style
.getPropertyValue("--bs-secondary-color")
.trim() ||
"#8a93a2";
const borderColor =
style
.getPropertyValue("--cui-border-color")
.trim() ||
style
.getPropertyValue(
"--bs-border-color-translucent",
)
.trim() ||
style
.getPropertyValue("--bs-border-color")
.trim() ||
"rgba(255, 255, 255, 0.08)";
const primaryColor =
style.getPropertyValue("--cui-primary").trim() ||
style.getPropertyValue("--bs-primary").trim() ||
"#6366f1";
const theme =
document.documentElement.getAttribute(
"data-coreui-theme",
) ||
document.documentElement.getAttribute(
"data-bs-theme",
) ||
"dark";
return {
bodyColor,
secondaryColor,
borderColor,
primaryColor,
theme,
};
};
const colors = getCoreUIColors();
// 2. Base Configuration explicitly overriding ApexCharts SVG text fills
const baseConfig = {
chart: {
type: "line",
height: 360,
foreColor: colors.secondaryColor, // Sets default text/legend colors
background: "transparent",
},
colors: [colors.primaryColor],
theme: {
mode: colors.theme,
},
tooltip: {
theme: colors.theme,
},
grid: {
borderColor: colors.borderColor,
},
xaxis: {
labels: {
style: {
colors: colors.secondaryColor,
fontSize: "12px",
},
},
axisBorder: { color: colors.borderColor },
axisTicks: { color: colors.borderColor },
},
yaxis: {
labels: {
style: {
colors: colors.secondaryColor,
fontSize: "12px",
},
},
},
};
// Deep merge user options with base configuration
const options = Object.assign({}, baseConfig, userConfig);
options.chart = Object.assign(
{},
baseConfig.chart,
userConfig.chart,
);
options.theme = Object.assign(
{},
baseConfig.theme,
userConfig.theme,
);
options.tooltip = Object.assign(
{},
baseConfig.tooltip,
userConfig.tooltip,
);
const event = function(event) { // Preserve user primary colors if explicitly provided in PHP, otherwise use CoreUI's
const newData = event.detail if (!userConfig.colors) {
chart.updateSeries(newData.series, true, true); options.colors = baseConfig.colors;
chart.updateOptions(newData.options, true, true);
} }
name = el.getAttribute('id'); requestAnimationFrame(() => {
const chart = new ApexCharts(el, options);
chart.render();
// 3. Theme switch handler
const observer = new MutationObserver(() => {
const updated = getCoreUIColors();
chart.updateOptions(
{
chart: {
foreColor: updated.secondaryColor,
},
theme: { mode: updated.theme },
tooltip: { theme: updated.theme },
grid: { borderColor: updated.borderColor },
xaxis: {
labels: {
style: {
colors: updated.secondaryColor,
},
},
axisBorder: {
color: updated.borderColor,
},
axisTicks: {
color: updated.borderColor,
},
},
yaxis: {
labels: {
style: {
colors: updated.secondaryColor,
},
},
},
},
false,
true,
);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: [
"data-coreui-theme",
"data-bs-theme",
"class",
],
});
window.addEventListener(`chart-${name}-update`, event) // 4. Dynamic update event listener
const name = el.getAttribute("id");
const eventHandler = (event) => {
const detail = event.detail || event;
if (detail.series)
chart.updateSeries(detail.series, true);
if (detail.options)
chart.updateOptions(detail.options, true);
};
if (name) {
window.addEventListener(
`chart-${name}-update`,
eventHandler,
);
}
cleanup(() => { cleanup(() => {
observer.disconnect();
if (name)
window.removeEventListener(
`chart-${name}-update`,
eventHandler,
);
chart.destroy(); chart.destroy();
window.removeEventListener(`chart-${name}-update`, event)
}); });
}) });
});
});
},
);
} }

104
resources/js/alpine/quill.js

@ -1,46 +1,94 @@
import Quill from "quill";
import "quill/dist/quill.snow.css";
export default function alpineQuill(Alpine) { export default function alpineQuill(Alpine) {
Alpine.directive('quill', function (el, { expression }, { evaluate, cleanup }) { Alpine.directive(
const options = expression ? evaluate(expression) : {}; "quill",
const defaultOptions = { theme: 'snow', ...options }; (el, { expression }, { evaluateLater, cleanup }) => {
// 1. Fallback gracefully if x-quill has no parameters
const getContent = expression
? evaluateLater(expression)
: (cb) => cb({});
getContent((userOptions) => {
const options = Object.assign(
{
theme: "snow",
placeholder: "Write something...",
},
typeof userOptions === "object" ? userOptions : {},
);
// 2. Identify or create container DIV (Quill crashes on <textarea>)
let editorContainer;
let targetInput = el;
if (el.tagName === "TEXTAREA" || el.tagName === "INPUT") {
// Create a container DIV for Quill right before the textarea
editorContainer = document.createElement("div");
el.parentNode.insertBefore(editorContainer, el);
if (typeof window.Quill === 'undefined') { // Hide the textarea without breaking Livewire bindings
throw new Error('Quill must be loaded'); el.style.display = "none";
} else {
editorContainer = el;
} }
const container = document.createElement('div'); // 3. Instantiate Quill on the container DIV
el.appendChild(container); const quill = new Quill(editorContainer, options);
const quill = new window.Quill(container, defaultOptions);
// Populate initial content from textarea / Alpine model
if (targetInput.value && targetInput.value !== "<p><br></p>") {
quill.root.innerHTML = targetInput.value;
}
let isUpdating = false; let isUpdating = false;
// 1. Text changes handler // 4. Sync Quill edits -> hidden input -> Livewire
quill.on('text-change', () => { quill.on("text-change", () => {
if (isUpdating) return; if (isUpdating) return;
isUpdating = true; isUpdating = true;
const htmlContent = quill.root.innerHTML;
el.value = htmlContent === '<p><br></p>' ? '' : htmlContent; const html = quill.root.innerHTML;
el.dispatchEvent(new Event('input', { bubbles: true })); const value = html === "<p><br></p>" ? "" : html;
targetInput.value = value;
// Dispatch input & change events on the target input so Livewire detects it
targetInput.dispatchEvent(
new Event("input", { bubbles: true }),
);
targetInput.dispatchEvent(
new Event("change", { bubbles: true }),
);
isUpdating = false; isUpdating = false;
}); });
// 2. Element Value Getter/Setter // 5. Sync Livewire dynamic resets -> Quill
Object.defineProperty(el, 'value', { const handleExternalUpdate = () => {
get() { return quill.root.innerHTML; }, if (isUpdating) return;
set(newValue) { if (targetInput.value !== quill.root.innerHTML) {
if (isUpdating || newValue === quill.root.innerHTML) return;
isUpdating = true; isUpdating = true;
quill.root.innerHTML = newValue || ''; quill.root.innerHTML = targetInput.value || "";
isUpdating = false; isUpdating = false;
}, }
configurable: true };
});
quill.on('selection-change', (range) => { targetInput.addEventListener("input", handleExternalUpdate);
if (range == null) {
el.dispatchEvent(new Event('blur', { bubbles: true })); // 6. Cleanup DOM when Livewire removes the element
cleanup(() => {
targetInput.removeEventListener(
"input",
handleExternalUpdate,
);
if (editorContainer !== el) {
editorContainer.remove();
el.style.display = "";
} }
}); });
});
cleanup(() => el.innerHTML = '') },
}) );
} }

36
resources/js/alpine/select2.js

@ -4,14 +4,20 @@ export default function alpineSelect2(Alpine) {
function (el, { expression }, { evaluate, cleanup }) { function (el, { expression }, { evaluate, cleanup }) {
let config = evaluate(expression) || {}; let config = evaluate(expression) || {};
Promise.all([import("../plugin/select2.js")]).then(
([select2]) => {
if ( if (
(window.jQuery || window.jquery || window.$) && (window.jQuery || window.jquery || window.$) &&
typeof window.$.fn.select2 !== "undefined" typeof window.$.fn.select2 !== "undefined"
) { ) {
let modalContainer = el.closest(".modal"); let modalContainer = el.closest(".modal");
let select2Default = { let select2Default = {
placeholder: config.placeholder || "Select an option", placeholder:
dropdownParent: config.dropdownParent || modalContainer || el.parentElement, config.placeholder || "Select an option",
dropdownParent:
config.dropdownParent ||
modalContainer ||
el.parentElement,
allowClear: config.allowClear ?? true, allowClear: config.allowClear ?? true,
ajax: config.url ajax: config.url
? { ? {
@ -45,7 +51,8 @@ export default function alpineSelect2(Alpine) {
config = Object.assign(select2Default, config); config = Object.assign(select2Default, config);
let $el = $(el).select2(config); let $el = $(el).select2(config);
const $select2Container = $el.next(".select2-container"); const $select2Container =
$el.next(".select2-container");
const modelName = const modelName =
el.getAttribute("wire:model") || el.getAttribute("wire:model") ||
@ -58,7 +65,9 @@ export default function alpineSelect2(Alpine) {
$el.on("change", (e) => { $el.on("change", (e) => {
if (isUpdating) return; if (isUpdating) return;
isUpdating = true; isUpdating = true;
el.dispatchEvent(new Event("change", { bubbles: true })); el.dispatchEvent(
new Event("change", { bubbles: true }),
);
isUpdating = false; isUpdating = false;
}); });
@ -122,19 +131,30 @@ export default function alpineSelect2(Alpine) {
// Listen to Livewire DOM updates to force error styling inside wire:ignore wrapper // Listen to Livewire DOM updates to force error styling inside wire:ignore wrapper
if (window.Livewire) { if (window.Livewire) {
Livewire.hook("morph.updated", ({ el: updatedEl }) => { Livewire.hook(
if (updatedEl === el || updatedEl.contains(el)) { "morph.updated",
({ el: updatedEl }) => {
if (
updatedEl === el ||
updatedEl.contains(el)
) {
syncErrorState(); syncErrorState();
} }
}); },
);
} }
cleanup(() => { cleanup(() => {
$el.select2("destroy"); $el.select2("destroy");
window.removeEventListener(`${modelName}-clear`, event); window.removeEventListener(
`${modelName}-clear`,
event,
);
observer.disconnect(); observer.disconnect();
}); });
} }
}, },
); );
},
);
} }

40
resources/js/alpinejs.js

@ -1,23 +1,23 @@
// resources/js/app.js // resources/js/app.js
import { Alpine, Livewire } from '../../vendor/livewire/livewire/dist/livewire.esm'; import { Alpine, Livewire } from '../../vendor/livewire/livewire/dist/livewire.esm';
import bs from './alpine/bs'; import bs from './alpine/bs';
import alpineAxios from './alpine/axios'; import alpineAxios from './alpine/axios';
import alpineSelect2 from "./alpine/select2.js"; import alpineSelect2 from "./alpine/select2.js";
import alpineQuill from "./alpine/quill.js"; import alpineQuill from "./alpine/quill.js";
import alpineAsk from "./alpine/ask.js"; import alpineAsk from "./alpine/ask.js";
import AlpineChart from "./alpine/chart.js"; import AlpineChart from "./alpine/chart.js";
// 1. Expose both instances globally so Blade directives can see them // 1. Expose both instances globally so Blade directives can see them
window.Alpine = Alpine; window.Alpine = Alpine;
window.Livewire = Livewire; window.Livewire = Livewire;
// 2. Wrap plugin registration inside the initialization event listener // 2. Wrap plugin registration inside the initialization event listener
Alpine.plugin(bs); Alpine.plugin(bs);
Alpine.plugin(alpineAxios); Alpine.plugin(alpineAxios);
Alpine.plugin(alpineAsk); Alpine.plugin(alpineAsk);
Alpine.plugin(alpineSelect2); Alpine.plugin(alpineSelect2);
Alpine.plugin(alpineQuill); Alpine.plugin(alpineQuill);
Alpine.plugin(AlpineChart); Alpine.plugin(AlpineChart);
// 3. Boot Livewire // 3. Boot Livewire
Livewire.start(); Livewire.start();

4
resources/views/pages/finance/invoice/⚡form-modal/form-modal.blade.php

@ -57,10 +57,6 @@
@endif @endif
</div> </div>
@push('scripts')
@vite(['resources/js/plugin/select2.js'])
@endpush
<x-slot:footer> <x-slot:footer>
<x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal"/> <x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal"/>
<x-button type="submit" theme="primary" :label="__('ui/button.save')"/> <x-button type="submit" theme="primary" :label="__('ui/button.save')"/>

3
resources/views/pages/finance/product-mapping/⚡form-modal/form-modal.blade.php

@ -20,9 +20,6 @@
/> />
</div> </div>
@push('scripts')
@vite('resources/js/plugin/select2.js')
@endpush
<x-slot:footer> <x-slot:footer>
<x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal" /> <x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal" />
<x-button type="submit" theme="primary" :label="__('ui/button.save')" /> <x-button type="submit" theme="primary" :label="__('ui/button.save')" />

2
resources/views/pages/identity/users/index.blade.php

@ -41,7 +41,7 @@
/> />
@push('page-scripts') @push('page-scripts')
@vite(['resources/js/plugin/datatables.js', 'resources/js/plugin/select2.js']) @vite(['resources/js/plugin/datatables.js'])
{{ $dataTable->scripts(attributes: ['type' => 'module']) }} {{ $dataTable->scripts(attributes: ['type' => 'module']) }}
@endpush @endpush
</x-layouts.app> </x-layouts.app>

4
resources/views/pages/identity/users/⚡role-selection-modal/role-selection-modal.blade.php

@ -16,8 +16,4 @@
<x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal" /> <x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal" />
<x-button type="submit" theme="primary" :label="__('ui/button.save')" /> <x-button type="submit" theme="primary" :label="__('ui/button.save')" />
</x-slot:footer> </x-slot:footer>
@push('scripts')
@vite(['resources/js/plugin/select2.js'])
@endpush
</x-modal> </x-modal>

77
resources/views/widgets/charts/⚡role-distribution.blade.php

@ -6,26 +6,49 @@ use Livewire\Component;
new class extends Component new class extends Component
{ {
public function mount(): void #[Computed]
public function roleDistributions(): array
{ {
$this->dispatch('chart-role-distribution-update', return app(GetRoleDistributions::class)->fetch();
series: $this->roleDistributions['series'],
options: [
'labels' => $this->roleDistributions['categories'],
]
);
} }
#[Computed] #[Computed]
public function roleDistributions(): array public function chartOptions(): array
{ {
return app(GetRoleDistributions::class)->fetch(); return [
'chart' => [
'type' => 'pie',
'width' => '100%',
'height' => 360,
],
'series' => $this->roleDistributions['series'],
'labels' => $this->roleDistributions['categories'],
'plotOptions' => [
'pie' => [
'dataLabels' => [
'offset' => 1,
],
],
],
'theme' => [
'monochrome' => [
'enabled' => true,
],
],
'dataLabels' => [
'formatter' => 'JS_RAW_FORMATTER', // Replaced in Blade wrapper
],
'legend' => [
'show' => false,
],
];
} }
}; };
?> ?>
<div class="card bg-opacity-10 border-opacity-25 rounded-2 border"> <div class="card bg-opacity-10 border-opacity-25 rounded-2 border">
<div class="card-body"> <div class="card-body">
<div class="d-flex gap-3"> <div class="d-flex gap-3 mb-3">
<div class="icon-shape icon-md bg-secondary rounded-2 text-white"> <div class="icon-shape icon-md bg-secondary rounded-2 text-white">
@svg('tabler-user-shield', [ @svg('tabler-user-shield', [
'class' => 'fs-4', 'class' => 'fs-4',
@ -38,43 +61,19 @@ new class extends Component
</p> </p>
</div> </div>
</div> </div>
{{-- Direct JSON data binding with JS closure support for dataLabels --}}
<div <div
id="role-distribution" id="role-distribution"
x-chart="{ x-chart="{
series: [], ...{{ json_encode($this->chartOptions) }},
chart: {
width: 450,
type: 'pie',
},
labels: [],
plotOptions: {
pie: {
dataLabels: {
offset: 1,
},
},
},
theme: {
monochrome: {
enabled: true,
},
},
dataLabels: { dataLabels: {
formatter(val, opts) { formatter(val, opts) {
const name = opts.w.globals.labels[opts.seriesIndex]; const name = opts.w.globals.labels[opts.seriesIndex];
return [name, val.toFixed(1)+'%']; return [name, val.toFixed(1) + '%'];
}, }
},
legend: {
show: false
} }
}" }"
></div> ></div>
</div> </div>
</div> </div>
@once
@push('page-scripts')
@vite('resources/js/plugin/apexchart.js')
@endpush
@endonce

52
resources/views/widgets/charts/⚡user-growth.blade.php

@ -6,35 +6,39 @@ use Livewire\Component;
new class extends Component new class extends Component
{ {
public function mount(): void #[Computed]
public function userGrowth(): array
{ {
$this->dispatch('chart-user-growth-update', return app(GetUserGrowthTrends::class)->fetch();
series: [[ }
#[Computed]
public function chartOptions(): array
{
return [
'chart' => [
'type' => 'line',
'height' => 360,
'toolbar' => ['show' => false],
],
'series' => [[
'name' => __('domains/identity/dashboard.user_growth.series_name'), 'name' => __('domains/identity/dashboard.user_growth.series_name'),
'data' => $this->userGrowth['series'], 'data' => $this->userGrowth['series'],
]], ]],
options: [
'xaxis' => [ 'xaxis' => [
'categories' => $this->userGrowth['categories'], 'categories' => $this->userGrowth['categories'],
], ],
] 'colors' => ['#4f46e5'],
); ];
}
#[Computed]
public function userGrowth(): array
{
return app(GetUserGrowthTrends::class)->fetch();
} }
}; };
?> ?>
<div class="card bg-opacity-10 border-opacity-25 rounded-2 border"> <div class="card bg-opacity-10 border-opacity-25 rounded-2 border">
<div class="card-body"> <div class="card-body">
<div class="d-flex gap-3"> <div class="d-flex gap-3">
<div class="icon-shape icon-md bg-secondary rounded-2 text-white"> <div class="icon-shape icon-md bg-secondary rounded-2 text-white">
@svg('tabler-user-pin', [ @svg('tabler-user-pin', ['class' => 'fs-4'])
'class' => 'fs-4',
])
</div> </div>
<div> <div>
<h2 class="fs-4">{{ __('domains/identity/dashboard.user_growth.title') }}</h2> <h2 class="fs-4">{{ __('domains/identity/dashboard.user_growth.title') }}</h2>
@ -43,23 +47,11 @@ new class extends Component
</p> </p>
</div> </div>
</div> </div>
{{-- Direct JSON binding ensures initial render --}}
<div <div
id="user-growth" id="user-growth"
x-chart="{ x-chart='@json($this->chartOptions)'
chart: {
type: 'line',
height: 360,
toolbar: {show: false}
},
series: [],
colors: ['#4f46e5']
}"
></div> ></div>
</div> </div>
</div> </div>
@once
@push('page-scripts')
@vite('resources/js/plugin/apexchart.js')
@endpush
@endonce

Loading…
Cancel
Save