diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..6dcadae --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,61 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - 'website/**' + - '.github/workflows/deploy-docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: website + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build website + run: npm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: website/build + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 83fe2a6..d2ab167 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .phpunit.cache vendor +node_modules +.docusaurus +build diff --git a/readme.md b/readme.md index 0f8300d..5de5de1 100644 --- a/readme.md +++ b/readme.md @@ -1,878 +1,83 @@ # Tangible Object -A WordPress tool suite for building data-driven admin interfaces with a clean four-layer architecture. +A WordPress tool suite for building data-driven admin interfaces with a clean, layered architecture. -## Architecture - -The suite separates concerns into four distinct layers: - -1. **DataSet** - Define field types and coercion rules -2. **EditorLayout** - Compose the editor structure (sections, tabs, fields) -3. **Renderer** - Generate HTML output from the layout -4. **RequestHandler** - Handle CRUD operations with validation +**[View Full Documentation →](https://tangibleinc.github.io/object/)** -## Complete Example: Contact Form Entries Admin Page +## Quick Start -This example shows how to create a WordPress admin page for managing contact form entries, with list, create, and edit views. - -### Step 1: Define Your Data Object - -Create a file to define and configure your data object. This is typically done once during plugin initialization. +The easiest way to use Tangible Object is through the **DataView** API: ```php -add_string('name') - ->add_string('email') - ->add_string('message') - ->add_boolean('subscribe'); - - // ========================================================================= - // LAYER 2: Editor Composition - // ========================================================================= - $layout = new Layout($dataset); - - $layout->section('Contact Information', function(Section $s) { - $s->field('name') - ->placeholder('Full name') - ->help('The sender\'s full name'); - $s->field('email') - ->placeholder('email@example.com'); - }); - $layout->section('Message', function(Section $s) { - $s->field('message'); - $s->field('subscribe'); - }); - - $layout->sidebar(function(Sidebar $sb) { - $sb->actions(['save', 'delete']); - }); - - // ========================================================================= - // LAYER 3: UI Presentation - // ========================================================================= - $renderer = new HtmlRenderer(); - - // ========================================================================= - // LAYER 4: Request Handling - // ========================================================================= - // Note: CPT slugs must be 20 characters or less - $object = new PluralObject('contact_entry'); - $object->set_dataset($dataset); - $object->register([ - 'public' => false, - 'label' => 'Contact Entries', +add_action('admin_menu', function() { + $view = new DataView([ + 'slug' => 'contact_entry', + 'label' => 'Contact Entry', + 'fields' => [ + 'name' => 'string', + 'email' => 'email', + 'message' => 'text', + 'subscribe' => 'boolean', + ], + 'ui' => [ + 'menu_label' => 'Contact Entries', + 'icon' => 'dashicons-email', + ], ]); - $handler = new PluralHandler($object); - $handler + $view->get_handler() ->add_validator('name', Validators::required()) ->add_validator('email', Validators::required()) - ->add_validator('email', Validators::email()) - ->before_create(function($data) { - $data['created_at'] = current_time('mysql'); - return $data; - }); - - return [ - 'dataset' => $dataset, - 'layout' => $layout, - 'renderer' => $renderer, - 'handler' => $handler, - ]; -} -``` - -### Step 2: Create the Admin Page - -Register the admin menu and handle the different views. - -```php -list(); - $entities = $result->get_entities(); - - // Prepare data for rendering - $rows = array_map(function($entity) { - return [ - 'id' => $entity->get_id(), - 'name' => $entity->get('name'), - 'email' => $entity->get('email'), - 'message' => $entity->get('message'), - 'subscribe' => $entity->get('subscribe'), - ]; - }, $entities); - - // Page header - ?> -
-

- Contact Entries - Add New -

- - -

No contact entries found.

- - - - - - - - - - - - - - - - - - - - -
NameEmailSubscribedActions
- - Edit - - | - - Delete - -
- -
- -
-

Add New Contact Entry

- - -
- -
- - -
- - - tag) - // For now, we render the full form and strip tags, or render fields manually - echo $renderer->render_editor($layout, $data); - ?> -
- -

- - ← Back to list - -

-
- read($id); - - if ($result->is_error()) { - wp_die('Contact entry not found.'); - } - - $entity = $result->get_entity(); - $data = [ - 'name' => $entity->get('name'), - 'email' => $entity->get('email'), - 'message' => $entity->get('message'), - 'subscribe' => $entity->get('subscribe'), - ]; - - ?> -
-

Edit Contact Entry

- - -
- -
- - -
- - - render_editor($layout, $data); ?> -
- -

- - ← Back to list - -

-
- create([ - 'name' => sanitize_text_field($_POST['name'] ?? ''), - 'email' => sanitize_email($_POST['email'] ?? ''), - 'message' => sanitize_textarea_field($_POST['message'] ?? ''), - 'subscribe' => !empty($_POST['subscribe']), - ]); - - if ($result->is_error()) { - // Re-render form with errors - render_contact_create_view($contact, $result->get_errors(), $_POST); - } else { - // Redirect to edit view with success message - $new_id = $result->get_entity()->get_id(); - wp_redirect(admin_url('admin.php?page=contact-entries&action=edit&id=' . $new_id . '&created=1')); - exit; - } - break; - - case 'edit': - // Verify nonce - if (!wp_verify_nonce($_POST['_wpnonce'], 'update_contact_' . $id)) { - wp_die('Security check failed.'); - } - - // Attempt to update - $result = $handler->update($id, [ - 'name' => sanitize_text_field($_POST['name'] ?? ''), - 'email' => sanitize_email($_POST['email'] ?? ''), - 'message' => sanitize_textarea_field($_POST['message'] ?? ''), - 'subscribe' => !empty($_POST['subscribe']), - ]); - - if ($result->is_error()) { - // Re-render form with errors - render_contact_edit_view($contact, $id, $result->get_errors()); - } else { - // Redirect back with success message - wp_redirect(admin_url('admin.php?page=contact-entries&action=edit&id=' . $id . '&updated=1')); - exit; - } - break; - - case 'delete': - // Verify nonce - if (!wp_verify_nonce($_GET['_wpnonce'], 'delete_contact_' . $id)) { - wp_die('Security check failed.'); - } - - // Delete the entry - $handler->delete($id); - - // Redirect to list - wp_redirect(admin_url('admin.php?page=contact-entries&deleted=1')); - exit; - break; - } -} -``` - -## DataSet Field Types - -```php -$dataset = new DataSet(); -$dataset->add_string('title'); // Text fields -$dataset->add_integer('count'); // Number fields (renders as type="number") -$dataset->add_boolean('is_active'); // Checkbox fields -``` - -Type coercion happens automatically: -- Strings like `'5'` become integers when the field is `add_integer()` -- Values like `'yes'`, `'true'`, `'1'`, `'on'` become `true` for boolean fields - -## EditorLayout Structure - -### Sections - -```php -$layout->section('Section Label', function(Section $s) { - $s->field('field_name') - ->placeholder('Placeholder text') - ->help('Help text shown below the field') - ->readonly() // Make field read-only - ->width('50%'); // Set field width - - $s->columns(2); // Display fields in 2 columns - $s->condition('other_field', true); // Show section only when other_field is true -}); -``` - -### Tabs - -```php -use Tangible\EditorLayout\Tabs; -use Tangible\EditorLayout\Tab; - -$layout->tabs(function(Tabs $tabs) { - $tabs->tab('Content', function(Tab $t) { - $t->field('title'); - $t->field('body'); - }); - $tabs->tab('Settings', function(Tab $t) { - $t->field('is_published'); - }); -}); -``` - -### Nesting - -Sections and tabs can be nested arbitrarily: - -```php -$layout->section('Main', function(Section $s) { - $s->field('title'); - - // Nested section - $s->section('Advanced', function(Section $nested) { - $nested->field('slug'); - }); - - // Tabs inside section - $s->tabs(function(Tabs $tabs) { - $tabs->tab('Details', function(Tab $t) { - $t->field('description'); - }); - }); -}); -``` - -### Sidebar - -```php -$layout->sidebar(function(Sidebar $sb) { - $sb->field('status')->readonly(); - $sb->actions(['save', 'delete']); -}); -``` - -## Validators - -Built-in validators: - -```php -use Tangible\RequestHandler\Validators; - -$handler - ->add_validator('field', Validators::required()) - ->add_validator('field', Validators::min_length(3)) - ->add_validator('field', Validators::max_length(100)) - ->add_validator('count', Validators::min(0)) - ->add_validator('count', Validators::max(100)) - ->add_validator('status', Validators::in(['draft', 'published'])) - ->add_validator('email', Validators::email()); -``` - -Custom validators: - -```php -$handler->add_validator('slug', function($value) { - if (preg_match('/[^a-z0-9-]/', $value)) { - return new \Tangible\RequestHandler\ValidationError( - 'Slug can only contain lowercase letters, numbers, and hyphens' - ); - } - return true; -}); -``` - -## Lifecycle Hooks - -```php -// Modify data before create -$handler->before_create(function(array $data) { - $data['created_at'] = current_time('mysql'); - return $data; -}); - -// React after create -$handler->after_create(function($entity) { - do_action('my_plugin_contact_created', $entity); -}); - -// Modify data before update (receives entity and new data) -$handler->before_update(function($entity, array $data) { - $data['updated_at'] = current_time('mysql'); - return $data; -}); - -// React after update -$handler->after_update(function($entity) { - // Send notification, clear cache, etc. -}); - -// Cancel deletion by returning false -$handler->before_delete(function($entity) { - if ($entity->get('is_protected')) { - return false; // Cancels deletion - } - return true; -}); + ->add_validator('email', Validators::email()); -// React after delete (receives the deleted ID) -$handler->after_delete(function($id) { - // Cleanup related data + $view->register(); }); ``` -## SingularObject for Settings Pages +This creates: +- A Custom Post Type for your data +- An admin menu page with list, create, and edit views +- Form handling with validation and sanitization +- Full CRUD operations -For single-instance data like plugin settings, site configuration, or any data that exists as a single persistent instance, use `SingularObject` and `SingularHandler`. - -**Key differences from PluralObject:** -- No create/delete operations (the object always exists) -- Only read and update operations -- Data is stored in a single WordPress option by default -- Lifecycle hooks receive data arrays instead of entities - -### Complete Example: Plugin Settings Page - -This example shows how to create a WordPress admin page for managing plugin settings. - -#### Step 1: Define Your Settings Object - -```php -add_string('api_key') - ->add_string('api_endpoint') - ->add_boolean('debug_mode') - ->add_integer('cache_ttl') - ->add_integer('max_retries'); - - // ========================================================================= - // LAYER 2: Editor Composition - // ========================================================================= - $layout = new Layout($dataset); - - $layout->section('API Configuration', function(Section $s) { - $s->field('api_key') - ->placeholder('Enter your API key') - ->help('Your API key from the dashboard'); - $s->field('api_endpoint') - ->placeholder('https://api.example.com') - ->help('The API endpoint URL'); - }); - - $layout->section('Performance', function(Section $s) { - $s->field('cache_ttl') - ->help('Cache time-to-live in seconds (0 to disable)'); - $s->field('max_retries') - ->help('Maximum retry attempts for failed requests'); - }); - - $layout->section('Development', function(Section $s) { - $s->field('debug_mode') - ->help('Enable detailed logging for troubleshooting'); - }); - - $layout->sidebar(function(Sidebar $sb) { - $sb->actions(['save']); - }); - - // ========================================================================= - // LAYER 3: UI Presentation - // ========================================================================= - $renderer = new HtmlRenderer(); - - // ========================================================================= - // LAYER 4: Request Handling - // ========================================================================= - $object = new SingularObject('my_plugin_settings'); - $object->set_dataset($dataset); - - $handler = new SingularHandler($object); - $handler - ->add_validator('api_key', Validators::required()) - ->add_validator('cache_ttl', Validators::min(0)) - ->add_validator('max_retries', Validators::min(0)) - ->add_validator('max_retries', Validators::max(10)) - ->before_update(function($current, $data) { - // Clear cache when TTL changes - if (($current['cache_ttl'] ?? 0) !== ($data['cache_ttl'] ?? 0)) { - delete_transient('my_plugin_api_cache'); - } - return $data; - }) - ->after_update(function($data) { - // Log settings change - if ($data['debug_mode']) { - error_log('Plugin settings updated'); - } - }); - - return [ - 'dataset' => $dataset, - 'layout' => $layout, - 'renderer' => $renderer, - 'handler' => $handler, - ]; -} -``` - -#### Step 2: Create the Admin Page - -```php -read(); - $data = $result->get_data(); - - ?> -
-

My Plugin Settings

- - -
-

-
- - - -
- -
- - -
- - render_editor($layout, $data); ?> -
-
- update([ - 'api_key' => sanitize_text_field($_POST['api_key'] ?? ''), - 'api_endpoint' => esc_url_raw($_POST['api_endpoint'] ?? ''), - 'debug_mode' => !empty($_POST['debug_mode']), - 'cache_ttl' => (int) ($_POST['cache_ttl'] ?? 3600), - 'max_retries' => (int) ($_POST['max_retries'] ?? 3), - ]); - - if ($result->is_error()) { - // Re-render form with errors - render_settings_form($settings, $result->get_errors()); - } else { - // Re-render with success message - render_settings_form($settings, [], 'Settings saved successfully.'); - } -} -``` - -### SingularHandler Operations - -Unlike `PluralHandler`, `SingularHandler` only provides two operations: - -```php -// Read current values -$result = $handler->read(); -$data = $result->get_data(); // Returns associative array of all field values - -// Update values (partial updates supported) -$result = $handler->update([ - 'api_key' => 'new-key', - 'debug_mode' => true, -]); - -if ($result->is_success()) { - $updated_data = $result->get_data(); -} -``` - -### SingularHandler Lifecycle Hooks - -The `SingularHandler` supports `before_update` and `after_update` hooks with signatures different from `PluralHandler`: - -```php -// Modify data before update (receives current data and new data) -$handler->before_update(function(array $current, array $data) { - // $current = existing values before update - // $data = new values being saved - - // You can compare to detect changes - if ($current['api_key'] !== $data['api_key']) { - // API key changed, maybe invalidate tokens - } - - return $data; // Return modified data -}); - -// React after update (receives the updated data) -$handler->after_update(function(array $data) { - // $data = all field values after update - do_action('my_plugin_settings_updated', $data); -}); -``` - -### Custom Storage - -By default, `SingularObject` uses `OptionStorage` which stores data in a single WordPress option. You can provide custom storage by implementing the `SingularStorage` interface: - -```php -use Tangible\DataObject\SingularStorage; - -class NetworkOptionStorage implements SingularStorage { - protected array $values = []; - protected string $slug; - - public function __construct(string $slug) { - $this->slug = $slug; - } +## Architecture - public function set(string $slug, mixed $value): void { - $this->values[$slug] = $value; - } +For advanced customization, Tangible Object exposes four underlying layers: - public function get(string $slug): mixed { - return $this->values[$slug] ?? null; - } +1. **DataSet** - Define field types and coercion rules +2. **EditorLayout** - Compose the editor structure (sections, tabs, fields) +3. **Renderer** - Generate HTML output from the layout +4. **RequestHandler** - Handle CRUD operations with validation - public function save(): void { - update_site_option($this->slug, $this->values); - } +[Learn more about the architecture →](https://tangibleinc.github.io/object/advanced/architecture) - public function load(): void { - $this->values = get_site_option($this->slug, []); - } -} +## Documentation -// Use custom storage -$storage = new NetworkOptionStorage('my_network_settings'); -$object = new SingularObject('my_network_settings', $storage); -``` +- [Getting Started](https://tangibleinc.github.io/object/getting-started/quick-start) +- [DataView Configuration](https://tangibleinc.github.io/object/dataview/configuration) +- [Field Types](https://tangibleinc.github.io/object/dataview/field-types) +- [Layouts](https://tangibleinc.github.io/object/layouts/overview) +- [Validation](https://tangibleinc.github.io/object/dataview/validation) +- [Examples](https://tangibleinc.github.io/object/examples/settings-page) ## Requirements - PHP 8.0+ - WordPress 5.0+ + +## License + +MIT diff --git a/website/babel.config.js b/website/babel.config.js new file mode 100644 index 0000000..e00595d --- /dev/null +++ b/website/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [require.resolve('@docusaurus/core/lib/babel/preset')], +}; diff --git a/website/docs/advanced/architecture.md b/website/docs/advanced/architecture.md new file mode 100644 index 0000000..39e2d25 --- /dev/null +++ b/website/docs/advanced/architecture.md @@ -0,0 +1,212 @@ +--- +sidebar_position: 1 +title: Architecture +description: Understanding the four-layer architecture +--- + +# Four-Layer Architecture + +Tangible Object separates concerns into four distinct layers. While DataView handles all of this for you, understanding the layers helps with advanced customization. + +## Layer Overview + +``` +┌─────────────────────────────────────────────┐ +│ DataView │ +│ (High-level orchestration) │ +└─────────────────────────────────────────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ +┌─────────┐ ┌─────────────┐ ┌──────────┐ +│ DataSet │ │EditorLayout │ │ Renderer │ +└─────────┘ └─────────────┘ └──────────┘ + │ │ │ + └────────────┬────┘ │ + ▼ │ + ┌──────────────┐ │ + │RequestHandler│◄──────────────┘ + └──────────────┘ +``` + +## Layer 1: DataSet + +Defines field types and handles type coercion. + +```php +use Tangible\DataObject\DataSet; + +$dataset = new DataSet(); +$dataset + ->add_string('title') + ->add_string('email') + ->add_integer('count') + ->add_boolean('is_active'); +``` + +### Type Coercion + +DataSet automatically coerces values: + +```php +$dataset->coerce([ + 'count' => '42', // String → Integer: 42 + 'is_active' => 'yes', // String → Boolean: true +]); +``` + +### Available Types + +- `add_string($name)` - Text values +- `add_integer($name)` - Whole numbers +- `add_boolean($name)` - True/false + +## Layer 2: EditorLayout + +Composes the editor structure with sections, tabs, and fields. + +```php +use Tangible\EditorLayout\Layout; +use Tangible\EditorLayout\Section; +use Tangible\EditorLayout\Tabs; +use Tangible\EditorLayout\Tab; +use Tangible\EditorLayout\Sidebar; + +$layout = new Layout($dataset); + +$layout->section('Details', function(Section $s) { + $s->field('title') + ->placeholder('Enter title') + ->help('The main title'); + $s->field('count'); +}); + +$layout->tabs(function(Tabs $tabs) { + $tabs->tab('Settings', function(Tab $t) { + $t->field('is_active'); + }); +}); + +$layout->sidebar(function(Sidebar $sb) { + $sb->actions(['save', 'delete']); +}); +``` + +## Layer 3: Renderer + +Generates HTML from the layout structure. + +```php +use Tangible\Renderer\HtmlRenderer; + +$renderer = new HtmlRenderer(); +$html = $renderer->render_editor($layout, [ + 'title' => 'My Item', + 'count' => 5, +]); +``` + +## Layer 4: RequestHandler + +Handles CRUD operations with validation. + +### PluralObject & PluralHandler + +For multiple items (like posts): + +```php +use Tangible\DataObject\PluralObject; +use Tangible\RequestHandler\PluralHandler; +use Tangible\RequestHandler\Validators; + +$object = new PluralObject('my_item'); +$object->set_dataset($dataset); +$object->register([ + 'public' => false, + 'label' => 'My Items', +]); + +$handler = new PluralHandler($object); +$handler + ->add_validator('title', Validators::required()) + ->before_create(function($data) { + $data['created_at'] = current_time('mysql'); + return $data; + }); + +// CRUD operations +$result = $handler->create(['title' => 'New Item', 'count' => 1]); +$result = $handler->read($id); +$result = $handler->update($id, ['count' => 2]); +$result = $handler->delete($id); +$result = $handler->list(); +``` + +### SingularObject & SingularHandler + +For single-instance data (like settings): + +```php +use Tangible\DataObject\SingularObject; +use Tangible\RequestHandler\SingularHandler; + +$object = new SingularObject('my_settings'); +$object->set_dataset($dataset); + +$handler = new SingularHandler($object); + +// Only read and update +$result = $handler->read(); +$result = $handler->update(['title' => 'New Value']); +``` + +## Using Layers Directly + +For maximum control, use the layers directly instead of DataView: + +```php +// 1. Define data +$dataset = new DataSet(); +$dataset->add_string('name')->add_string('email'); + +// 2. Create layout +$layout = new Layout($dataset); +$layout->section('Contact', function(Section $s) { + $s->field('name'); + $s->field('email'); +}); + +// 3. Set up handler +$object = new PluralObject('contact'); +$object->set_dataset($dataset); +$object->register(); + +$handler = new PluralHandler($object); +$handler->add_validator('email', Validators::email()); + +// 4. Create renderer +$renderer = new HtmlRenderer(); + +// 5. Build admin page manually +add_action('admin_menu', function() use ($layout, $handler, $renderer) { + add_menu_page( + 'Contacts', + 'Contacts', + 'manage_options', + 'contacts', + function() use ($layout, $handler, $renderer) { + // Handle requests and render + } + ); +}); +``` + +## When to Use Layers Directly + +Use the layers directly when you need: +- Custom admin page structure +- Non-standard workflows +- Integration with existing systems +- Maximum flexibility + +For most cases, DataView provides everything you need with less code. diff --git a/website/docs/advanced/custom-field-types.md b/website/docs/advanced/custom-field-types.md new file mode 100644 index 0000000..20339c2 --- /dev/null +++ b/website/docs/advanced/custom-field-types.md @@ -0,0 +1,178 @@ +--- +sidebar_position: 2 +title: Custom Field Types +description: Registering custom field types +--- + +# Custom Field Types + +You can register custom field types to extend DataView's capabilities. + +## Registration + +Access the field registry and register your type: + +```php +use Tangible\DataObject\DataSet; + +$view = new DataView([...]); +$registry = $view->get_field_registry(); + +$registry->register_type('phone', [ + 'dataset' => DataSet::TYPE_STRING, + 'sanitizer' => function($value) { + return preg_replace('/[^0-9+\-\s()]/', '', $value); + }, + 'schema' => ['type' => 'varchar', 'length' => 20], + 'input' => 'tel', +]); +``` + +## Registration Options + +| Option | Type | Description | +|--------|------|-------------| +| `dataset` | string | DataSet type constant (`TYPE_STRING`, `TYPE_INTEGER`, `TYPE_BOOLEAN`) | +| `sanitizer` | callable | Function to sanitize input values | +| `schema` | array | Database column definition (for `'storage' => 'database'`) | +| `input` | string | HTML input type | + +## Example: Currency Field + +Store prices as cents (integers), but allow decimal input: + +```php +$registry->register_type('currency', [ + 'dataset' => DataSet::TYPE_INTEGER, + 'sanitizer' => function($value) { + // Convert dollars to cents + $float = floatval(str_replace(['$', ','], '', $value)); + return (int) round($float * 100); + }, + 'schema' => ['type' => 'int', 'length' => 11], + 'input' => 'text', +]); + +// Usage +$view = new DataView([ + 'slug' => 'product', + 'label' => 'Product', + 'fields' => [ + 'name' => 'string', + 'price' => 'currency', // Custom type + ], +]); +``` + +## Example: Slug Field + +Auto-sanitize to URL-friendly format: + +```php +$registry->register_type('slug', [ + 'dataset' => DataSet::TYPE_STRING, + 'sanitizer' => function($value) { + return sanitize_title($value); + }, + 'schema' => ['type' => 'varchar', 'length' => 200], + 'input' => 'text', +]); +``` + +## Example: JSON Field + +Store structured data as JSON: + +```php +$registry->register_type('json', [ + 'dataset' => DataSet::TYPE_STRING, + 'sanitizer' => function($value) { + if (is_string($value)) { + // Validate JSON + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $value; + } + return '{}'; + } + return json_encode($value); + }, + 'schema' => ['type' => 'longtext'], + 'input' => 'textarea', +]); +``` + +## Example: Color Picker + +```php +$registry->register_type('color', [ + 'dataset' => DataSet::TYPE_STRING, + 'sanitizer' => function($value) { + // Validate hex color + if (preg_match('/^#[0-9A-Fa-f]{6}$/', $value)) { + return $value; + } + return '#000000'; + }, + 'schema' => ['type' => 'varchar', 'length' => 7], + 'input' => 'color', +]); +``` + +## Registration Timing + +Register custom types **before** creating the DataView that uses them: + +```php +// Get a registry instance +$registry = new \Tangible\DataView\FieldTypeRegistry(); + +// Register custom types +$registry->register_type('phone', [...]); +$registry->register_type('currency', [...]); + +// Create DataView (it will use the same registry) +$view = new DataView([ + 'slug' => 'contact', + 'label' => 'Contact', + 'fields' => [ + 'name' => 'string', + 'phone' => 'phone', // Custom type + 'budget' => 'currency', // Custom type + ], +]); +``` + +## Sharing Types Across Views + +For multiple DataViews sharing custom types: + +```php +// Create and configure a shared registry +$registry = new \Tangible\DataView\FieldTypeRegistry(); +$registry->register_type('phone', [...]); + +// Use it for multiple views +$view1 = new DataView(['fields' => ['phone' => 'phone'], ...]); +$view2 = new DataView(['fields' => ['mobile' => 'phone'], ...]); +``` + +## Database Schema Options + +For `'storage' => 'database'`, the schema defines the column: + +```php +'schema' => [ + 'type' => 'varchar', // Column type + 'length' => 255, // Column length +] + +// Common types: +// varchar(length) +// int(length) +// tinyint(1) - for booleans +// text +// longtext +// date +// datetime +``` diff --git a/website/docs/advanced/i18n.md b/website/docs/advanced/i18n.md new file mode 100644 index 0000000..1fb2706 --- /dev/null +++ b/website/docs/advanced/i18n.md @@ -0,0 +1,167 @@ +--- +sidebar_position: 4 +title: Internationalization +description: Making your DataView translatable +--- + +# Internationalization (i18n) + +WordPress i18n tools scan source files for translation function calls. To ensure your DataView labels are translatable, pass pre-translated strings. + +## Basic Usage + +Instead of a simple string, pass an array with translated labels: + +```php +$view = new DataView([ + 'slug' => 'book', + 'label' => [ + 'singular' => __('Book', 'my-plugin'), + 'plural' => __('Books', 'my-plugin'), + ], + 'fields' => [ + 'title' => 'string', + ], +]); +``` + +## Available Label Keys + +| Key | Default | Description | +|-----|---------|-------------| +| `singular` | (required) | Singular form ("Book") | +| `plural` | Auto-generated | Plural form ("Books") | +| `all_items` | `{plural}` | List page title | +| `add_new` | "Add New" | Add button text | +| `add_new_item` | "Add New {singular}" | Create page title | +| `edit_item` | "Edit {singular}" | Edit page title | +| `new_item` | "New {singular}" | New item text | +| `view_item` | "View {singular}" | View item text | +| `view_items` | "View {plural}" | View items text | +| `search_items` | "Search {plural}" | Search text | +| `not_found` | "No {plural} found" | Empty list message | +| `not_found_in_trash` | "No {plural} found in Trash" | Empty trash message | +| `settings` | "{singular} Settings" | Settings page title | +| `item_created` | "Item created successfully." | Create notice | +| `item_updated` | "Item updated successfully." | Update notice | +| `item_deleted` | "Item deleted successfully." | Delete notice | +| `settings_saved` | "Settings saved successfully." | Settings notice | +| `menu_name` | `{plural}` | WordPress menu name | + +## Complete Example + +```php +$view = new DataView([ + 'slug' => 'book', + 'label' => [ + // Required + 'singular' => __('Book', 'my-plugin'), + 'plural' => __('Books', 'my-plugin'), + + // Page titles + 'all_items' => __('All Books', 'my-plugin'), + 'add_new_item' => __('Add New Book', 'my-plugin'), + 'edit_item' => __('Edit Book', 'my-plugin'), + + // WordPress labels + 'add_new' => __('Add New', 'my-plugin'), + 'new_item' => __('New Book', 'my-plugin'), + 'view_item' => __('View Book', 'my-plugin'), + 'view_items' => __('View Books', 'my-plugin'), + 'search_items' => __('Search Books', 'my-plugin'), + 'not_found' => __('No books found', 'my-plugin'), + 'not_found_in_trash' => __('No books found in Trash', 'my-plugin'), + + // Success notices + 'item_created' => __('Book created successfully.', 'my-plugin'), + 'item_updated' => __('Book updated successfully.', 'my-plugin'), + 'item_deleted' => __('Book deleted successfully.', 'my-plugin'), + + // Menu + 'menu_name' => __('Books', 'my-plugin'), + ], + 'fields' => [...], + 'ui' => [ + 'menu_label' => __('Books', 'my-plugin'), + ], +]); +``` + +## Settings Page Example + +```php +$view = new DataView([ + 'slug' => 'my_plugin_settings', + 'label' => [ + 'singular' => __('Settings', 'my-plugin'), + 'settings' => __('Plugin Settings', 'my-plugin'), + 'settings_saved' => __('Settings saved.', 'my-plugin'), + ], + 'storage' => 'option', + 'mode' => 'singular', + 'ui' => [ + 'menu_label' => __('My Plugin', 'my-plugin'), + 'parent' => 'options-general.php', + ], +]); +``` + +## Minimal Setup + +At minimum, provide translated `singular` and `plural` labels: + +```php +$view = new DataView([ + 'slug' => 'product', + 'label' => [ + 'singular' => __('Product', 'my-plugin'), + 'plural' => __('Products', 'my-plugin'), + ], + 'fields' => ['name' => 'string'], +]); +``` + +Other labels are auto-generated from these: +- "Add New Product" from singular +- "Edit Product" from singular +- "Products" for menu from plural + +## Accessing Labels + +Get labels programmatically: + +```php +$config = $view->get_config(); + +$config->get_singular_label(); // "Product" +$config->get_plural_label(); // "Products" +$config->get_label('add_new_item'); // "Add New Product" +$config->get_label('custom', 'Default'); // "Default" +``` + +## Translation Files + +Ensure your plugin generates proper translation files: + +```bash +# Generate POT file +wp i18n make-pot . languages/my-plugin.pot + +# Create translations +# languages/my-plugin-de_DE.po + +# Compile to MO +wp i18n make-mo languages/ +``` + +Load translations in your plugin: + +```php +add_action('init', function() { + load_plugin_textdomain( + 'my-plugin', + false, + dirname(plugin_basename(__FILE__)) . '/languages/' + ); +}); +``` diff --git a/website/docs/advanced/repeaters.md b/website/docs/advanced/repeaters.md new file mode 100644 index 0000000..cf33f48 --- /dev/null +++ b/website/docs/advanced/repeaters.md @@ -0,0 +1,245 @@ +--- +sidebar_position: 3 +title: Repeaters +description: Managing collections of sub-items +--- + +# Repeater Fields + +Repeater fields allow users to manage collections of sub-items within a single entity. Data is stored as JSON. + +## Basic Definition + +```php +'fields' => [ + 'name' => 'string', + 'items' => [ + 'type' => 'repeater', + 'sub_fields' => [ + ['name' => 'title', 'type' => 'string'], + ['name' => 'quantity', 'type' => 'integer'], + ['name' => 'active', 'type' => 'boolean'], + ], + ], +] +``` + +## Sub-Field Types + +Repeater sub-fields support JSON-compatible primitive types: + +| Type | Description | +|------|-------------| +| `string` | Text values | +| `integer` | Numeric values | +| `boolean` | True/false values | + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `sub_fields` | array | (required) | Array of sub-field definitions | +| `layout` | string | `'table'` | Layout style: `'table'` or `'block'` | +| `min_rows` | int | - | Minimum number of rows | +| `max_rows` | int | - | Maximum number of rows | +| `button_label` | string | - | Custom "Add" button text | +| `default` | array | `[]` | Default rows for new items | +| `description` | string | - | Help text for the field | + +## Sub-Field Options + +Each sub-field accepts: + +| Option | Type | Description | +|--------|------|-------------| +| `name` | string | Field identifier (required) | +| `type` | string | Field type (required) | +| `label` | string | Display label | +| `placeholder` | string | Placeholder text | +| `description` | string | Help text | +| `min` | int | Minimum value (for integers) | +| `max` | int | Maximum value (for integers) | + +## Complete Example + +```php +$view = new DataView([ + 'slug' => 'invoice', + 'label' => 'Invoice', + 'fields' => [ + 'customer_name' => 'string', + 'line_items' => [ + 'type' => 'repeater', + 'label' => 'Line Items', + 'description' => 'Add products or services', + 'layout' => 'table', + 'min_rows' => 1, + 'max_rows' => 50, + 'button_label' => 'Add Line Item', + 'sub_fields' => [ + [ + 'name' => 'description', + 'type' => 'string', + 'label' => 'Description', + 'placeholder' => 'Product or service', + ], + [ + 'name' => 'quantity', + 'type' => 'integer', + 'label' => 'Qty', + 'min' => 1, + ], + [ + 'name' => 'unit_price', + 'type' => 'integer', + 'label' => 'Price (cents)', + 'min' => 0, + ], + [ + 'name' => 'taxable', + 'type' => 'boolean', + 'label' => 'Tax', + ], + ], + 'default' => [ + ['description' => '', 'quantity' => 1, 'unit_price' => 0, 'taxable' => true], + ], + ], + ], +]); +``` + +## Reading Repeater Data + +Repeater data is stored as JSON. Decode it when reading: + +```php +$handler = $view->get_handler(); +$result = $handler->read($id); +$entity = $result->get_entity(); + +$line_items = json_decode($entity->get('line_items'), true); + +foreach ($line_items as $item) { + echo $item['description'] . ': '; + echo $item['quantity'] . ' × ' . $item['unit_price']; +} +``` + +## Writing Repeater Data + +Encode data as JSON when creating or updating: + +```php +$handler->create([ + 'customer_name' => 'John Doe', + 'line_items' => json_encode([ + ['description' => 'Widget', 'quantity' => 2, 'unit_price' => 1000, 'taxable' => true], + ['description' => 'Service', 'quantity' => 1, 'unit_price' => 5000, 'taxable' => false], + ]), +]); +``` + +## Data Structure + +Rows include a `key` property for identification: + +```json +[ + {"key": "abc123", "description": "Widget", "quantity": 2, "unit_price": 1000}, + {"key": "def456", "description": "Service", "quantity": 1, "unit_price": 5000} +] +``` + +The `key` is managed automatically by the renderer. + +## Layout Options + +### Table Layout + +Displays rows as a table with columns: + +```php +'layout' => 'table', +``` + +Best for: +- Many short fields +- Numeric data +- Quick scanning + +### Block Layout + +Displays each row as a card/block: + +```php +'layout' => 'block', +``` + +Best for: +- Fewer, longer fields +- Text content +- Complex sub-structures + +## Renderer Support + +| Renderer | Repeater Support | +|----------|------------------| +| HtmlRenderer | Basic table UI | +| TangibleFieldsRenderer | Full support with drag-and-drop | + +For the best repeater experience, use TangibleFieldsRenderer: + +```php +use Tangible\Renderer\TangibleFieldsRenderer; + +$view->set_renderer(new TangibleFieldsRenderer()); +``` + +## Security + +The repeater sanitizer: +- Strips nested arrays/objects (only primitives allowed) +- Sanitizes all string values +- Returns `[]` for invalid JSON +- Preserves the `key` field + +## Processing Repeater Data + +Common patterns for working with repeater data: + +### Calculate Totals + +```php +$handler->before_create(function($data) { + $items = json_decode($data['line_items'] ?? '[]', true); + $total = 0; + + foreach ($items as $item) { + $total += ($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0); + } + + $data['total'] = $total; + return $data; +}); +``` + +### Validate Rows + +```php +$handler->add_validator('line_items', function($value) { + $items = json_decode($value, true); + + if (empty($items)) { + return new ValidationError('At least one line item is required'); + } + + foreach ($items as $i => $item) { + if (empty($item['description'])) { + return new ValidationError("Row " . ($i + 1) . ": Description is required"); + } + } + + return true; +}); +``` diff --git a/website/docs/api-reference.md b/website/docs/api-reference.md new file mode 100644 index 0000000..6b49aba --- /dev/null +++ b/website/docs/api-reference.md @@ -0,0 +1,226 @@ +--- +sidebar_position: 100 +title: API Reference +description: Complete API reference for all classes +--- + +# API Reference + +Quick reference for all public APIs. + +## DataView + +### Constructor + +```php +$view = new DataView(array $config); +``` + +### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `register()` | `static` | Registers admin menu and hooks | +| `get_handler()` | `PluralHandler\|SingularHandler` | Request handler | +| `get_object()` | `PluralObject\|SingularObject` | Data object | +| `get_dataset()` | `DataSet` | Dataset instance | +| `get_config()` | `DataViewConfig` | Configuration | +| `get_field_registry()` | `FieldTypeRegistry` | Field registry | +| `url(string $action, ?int $id)` | `string` | Admin URL | +| `set_layout(callable $callback)` | `static` | Custom layout | +| `set_renderer(Renderer $renderer)` | `static` | Custom renderer | +| `handle_request()` | `void` | Handle current request | + +## DataViewConfig + +### Properties (readonly) + +| Property | Type | Description | +|----------|------|-------------| +| `$slug` | string | Unique identifier | +| `$label` | string | Singular label | +| `$labels` | array | Full labels array | +| `$fields` | array | Field definitions | +| `$field_configs` | array | Full field configs | +| `$storage` | string | Storage type | +| `$mode` | string | plural/singular | +| `$capability` | string | Required capability | +| `$storage_options` | array | Storage options | +| `$ui` | array | UI configuration | + +### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `is_plural()` | `bool` | Is plural mode | +| `is_singular()` | `bool` | Is singular mode | +| `get_menu_page()` | `string` | Menu slug | +| `get_menu_label()` | `string` | Menu label | +| `get_parent_menu()` | `?string` | Parent menu | +| `get_icon()` | `string` | Menu icon | +| `get_position()` | `?int` | Menu position | +| `get_singular_label()` | `string` | Singular label | +| `get_plural_label()` | `?string` | Plural label | +| `get_label(string $key, ?string $fallback)` | `?string` | Specific label | +| `get_field_config(string $name)` | `?array` | Field config | + +## PluralHandler + +### CRUD Methods + +```php +$result = $handler->create(array $data); +$result = $handler->read(int $id); +$result = $handler->update(int $id, array $data); +$result = $handler->delete(int $id); +$result = $handler->list(); +``` + +### Validation + +```php +$handler->add_validator(string $field, callable $validator); +``` + +### Lifecycle Hooks + +```php +$handler->before_create(callable $callback); +$handler->after_create(callable $callback); +$handler->before_update(callable $callback); +$handler->after_update(callable $callback); +$handler->before_delete(callable $callback); +$handler->after_delete(callable $callback); +``` + +## SingularHandler + +### Methods + +```php +$result = $handler->read(); +$result = $handler->update(array $data); +$handler->add_validator(string $field, callable $validator); +$handler->before_update(callable $callback); +$handler->after_update(callable $callback); +``` + +## Validators + +```php +use Tangible\RequestHandler\Validators; + +Validators::required(); +Validators::email(); +Validators::min_length(int $length); +Validators::max_length(int $length); +Validators::min(int $value); +Validators::max(int $value); +Validators::in(array $values); +``` + +## Result Object + +```php +$result->is_success(); +$result->is_error(); +$result->get_entity(); // PluralHandler +$result->get_entities(); // PluralHandler::list() +$result->get_data(); // SingularHandler +$result->get_errors(); +``` + +## ValidationError + +```php +use Tangible\RequestHandler\ValidationError; + +$error = new ValidationError(string $message, ?string $field = null); +$error->get_message(); +$error->get_field(); +``` + +## Layout Classes + +### Layout + +```php +$layout = new Layout(DataSet $dataset); +$layout->section(string $label, callable $callback); +$layout->tabs(callable $callback); +$layout->sidebar(callable $callback); +$layout->get_structure(); +$layout->get_dataset(); +``` + +### Section + +```php +$section->field(string $name); +$section->section(string $label, callable $callback); +$section->tabs(callable $callback); +$section->columns(int $count); +$section->condition(string $field, mixed $value); +``` + +### Field (in Section) + +```php +$section->field('name') + ->placeholder(string $text) + ->help(string $text) + ->readonly() + ->width(string $width); +``` + +### Tabs + +```php +$tabs->tab(string $label, callable $callback); +``` + +### Tab + +```php +$tab->field(string $name); +$tab->section(string $label, callable $callback); +$tab->tabs(callable $callback); +``` + +### Sidebar + +```php +$sidebar->field(string $name); +$sidebar->actions(array $actions); +``` + +## FieldTypeRegistry + +```php +$registry->has_type(string $type); +$registry->get_dataset_type(string $type); +$registry->get_sanitizer(string $type); +$registry->get_schema(string $type); +$registry->get_input_type(string $type); +$registry->register_type(string $name, array $config); +``` + +## Renderer Interface + +```php +interface Renderer { + public function render_editor(Layout $layout, array $data = []): string; + public function render_list(DataSet $dataset, array $entities): string; +} +``` + +## UrlBuilder + +```php +$builder = new UrlBuilder(DataViewConfig $config); +$builder->url(string $action, ?int $id, array $extra); +$builder->url_with_nonce(string $action, ?int $id, string $nonce_action); +$builder->get_current_action(); +$builder->get_current_id(); +$builder->get_nonce_action(string $action, ?int $id); +``` diff --git a/website/docs/dataview/configuration.md b/website/docs/dataview/configuration.md new file mode 100644 index 0000000..22d88f0 --- /dev/null +++ b/website/docs/dataview/configuration.md @@ -0,0 +1,123 @@ +--- +sidebar_position: 2 +title: Configuration +description: DataView configuration options reference +--- + +# Configuration + +This page documents all configuration options available when creating a DataView. + +## Required Options + +| Option | Type | Description | +|--------|------|-------------| +| `slug` | string | Unique identifier. Must be lowercase alphanumeric with underscores, starting with a letter or underscore. For CPT storage, must be 20 characters or less. | +| `label` | string\|array | Singular label or array with label configuration. See [Internationalization](/advanced/i18n). | +| `fields` | array | Field name to type mapping. See [Field Types](/dataview/field-types). | + +## Optional Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `storage` | string | `'cpt'` | Storage backend: `'cpt'`, `'database'`, or `'option'`. | +| `mode` | string | `'plural'` | `'plural'` for multiple items, `'singular'` for settings. | +| `capability` | string | `'manage_options'` | Required WordPress capability to access the admin page. | +| `storage_options` | array | `[]` | Additional options passed to storage adapter. | +| `ui` | array | See below | Admin UI configuration. | + +## UI Options + +```php +'ui' => [ + 'menu_page' => 'my_page', // Menu page slug (defaults to config slug) + 'menu_label' => 'My Items', // Menu label (defaults to config label) + 'parent' => null, // Parent menu slug (null for top-level) + 'icon' => 'dashicons-admin-generic', // Menu icon + 'position' => null, // Menu position (null for default) +] +``` + +### Parent Menu Options + +To add your page as a submenu: + +```php +// Under Settings +'parent' => 'options-general.php' + +// Under Tools +'parent' => 'tools.php' + +// Under a custom post type +'parent' => 'edit.php?post_type=product' + +// Under another plugin's menu +'parent' => 'my-plugin-slug' +``` + +## Complete Example + +```php +use Tangible\DataView\DataView; + +$view = new DataView([ + // Required + 'slug' => 'customer', + 'label' => [ + 'singular' => 'Customer', + 'plural' => 'Customers', + ], + 'fields' => [ + 'name' => 'string', + 'email' => 'email', + 'company' => 'string', + 'notes' => 'text', + 'is_active' => 'boolean', + 'created_at' => 'datetime', + ], + + // Optional + 'storage' => 'cpt', + 'mode' => 'plural', + 'capability' => 'manage_options', + + 'storage_options' => [ + // CPT-specific options + 'public' => false, + ], + + 'ui' => [ + 'menu_page' => 'customers', + 'menu_label' => 'Customers', + 'parent' => null, + 'icon' => 'dashicons-groups', + 'position' => 30, + ], +]); +``` + +## Configuration Object + +After creating a DataView, you can access the parsed configuration: + +```php +$config = $view->get_config(); + +// Access properties +$config->slug; // 'customer' +$config->label; // 'Customer' +$config->fields; // ['name' => 'string', ...] +$config->storage; // 'cpt' +$config->mode; // 'plural' +$config->capability; // 'manage_options' + +// Helper methods +$config->is_plural(); // true +$config->is_singular(); // false +$config->get_menu_page(); // 'customers' +$config->get_menu_label(); // 'Customers' +$config->get_parent_menu(); // null +$config->get_icon(); // 'dashicons-groups' +$config->get_position(); // 30 +``` diff --git a/website/docs/dataview/field-types.md b/website/docs/dataview/field-types.md new file mode 100644 index 0000000..6403e71 --- /dev/null +++ b/website/docs/dataview/field-types.md @@ -0,0 +1,118 @@ +--- +sidebar_position: 3 +title: Field Types +description: Available field types and their configuration +--- + +# Field Types + +DataView provides built-in field types that handle data type mapping, input sanitization, and HTML input rendering. + +## Built-in Types + +| Type | HTML Input | Sanitizer | Description | +|------|------------|-----------|-------------| +| `string` | text | `sanitize_text_field` | Single-line text | +| `text` | textarea | `sanitize_textarea_field` | Multi-line text | +| `email` | email | `sanitize_email` | Email address | +| `url` | url | `esc_url_raw` | URL | +| `integer` | number | `intval` | Whole numbers | +| `boolean` | checkbox | custom | True/false values | +| `date` | date | `sanitize_text_field` | Date (YYYY-MM-DD) | +| `datetime` | datetime-local | `sanitize_text_field` | Date and time | +| `repeater` | repeater | JSON sanitizer | Collection of sub-items | + +## Defining Fields + +Fields can be defined in two formats: + +### Simple Format + +Use a string for the type: + +```php +'fields' => [ + 'name' => 'string', + 'email' => 'email', + 'count' => 'integer', + 'notes' => 'text', +] +``` + +### Complex Format + +Use an array with a `type` key for additional configuration: + +```php +'fields' => [ + 'name' => [ + 'type' => 'string', + 'label' => 'Full Name', + 'placeholder' => 'Enter your name', + 'description' => 'Your legal name', + ], + 'quantity' => [ + 'type' => 'integer', + 'min' => 1, + 'max' => 100, + ], +] +``` + +The complex format is required for repeater fields and allows additional configuration for any type. + +## Field Configuration Options + +Common options available in complex format: + +| Option | Type | Description | +|--------|------|-------------| +| `type` | string | The field type (required) | +| `label` | string | Display label (auto-generated from name if not set) | +| `placeholder` | string | Placeholder text for input | +| `description` | string | Help text shown below the field | + +Type-specific options: + +| Type | Option | Description | +|------|--------|-------------| +| `integer` | `min` | Minimum value | +| `integer` | `max` | Maximum value | +| `text` | `rows` | Number of textarea rows | + +## Boolean Sanitization + +The boolean sanitizer accepts various truthy values: + +- `true`, `'1'`, `'true'`, `'yes'`, `'on'` → `true` +- `false`, `'0'`, `''`, `'no'`, any other value → `false` + +## Type Coercion + +DataView automatically coerces values based on field type: + +```php +// Integer field with string input +$handler->create(['count' => '5']); // Stored as integer 5 + +// Boolean field with various inputs +$handler->create(['active' => 'yes']); // Stored as true +$handler->create(['active' => '0']); // Stored as false +``` + +## Custom Field Types + +You can register custom field types. See [Custom Field Types](/advanced/custom-field-types) for details. + +```php +$registry = $view->get_field_registry(); + +$registry->register_type('phone', [ + 'dataset' => DataSet::TYPE_STRING, + 'sanitizer' => function($value) { + return preg_replace('/[^0-9+\-\s()]/', '', $value); + }, + 'schema' => ['type' => 'varchar', 'length' => 20], + 'input' => 'tel', +]); +``` diff --git a/website/docs/dataview/lifecycle-hooks.md b/website/docs/dataview/lifecycle-hooks.md new file mode 100644 index 0000000..dbe4d76 --- /dev/null +++ b/website/docs/dataview/lifecycle-hooks.md @@ -0,0 +1,223 @@ +--- +sidebar_position: 6 +title: Lifecycle Hooks +description: React to create, update, and delete operations +--- + +# Lifecycle Hooks + +Lifecycle hooks let you execute custom logic before or after CRUD operations. + +## Plural Mode Hooks + +### before_create + +Modify data before creating an entity: + +```php +$handler->before_create(function(array $data) { + $data['created_at'] = current_time('mysql'); + $data['created_by'] = get_current_user_id(); + return $data; +}); +``` + +### after_create + +React after an entity is created: + +```php +$handler->after_create(function($entity) { + // Send notification + wp_mail( + get_option('admin_email'), + 'New entry created', + 'A new ' . $entity->get('title') . ' was created.' + ); + + // Clear cache + delete_transient('my_items_list'); +}); +``` + +### before_update + +Modify data before updating. Receives the entity and new data: + +```php +$handler->before_update(function($entity, array $data) { + $data['updated_at'] = current_time('mysql'); + + // Track who made changes + $data['updated_by'] = get_current_user_id(); + + return $data; +}); +``` + +### after_update + +React after an update: + +```php +$handler->after_update(function($entity) { + // Clear specific cache + delete_transient('item_' . $entity->get_id()); + + // Log the change + do_action('my_plugin_item_updated', $entity); +}); +``` + +### before_delete + +Control whether deletion proceeds. Return `false` to cancel: + +```php +$handler->before_delete(function($entity) { + // Prevent deletion of protected items + if ($entity->get('is_protected')) { + return false; + } + + // Or check user permissions + if (!current_user_can('delete_others_posts')) { + return false; + } + + return true; +}); +``` + +### after_delete + +Clean up after deletion. Receives the deleted ID: + +```php +$handler->after_delete(function($id) { + // Clean up related data + global $wpdb; + $wpdb->delete('my_related_table', ['parent_id' => $id]); + + // Clear caches + delete_transient('my_items_list'); +}); +``` + +## Singular Mode Hooks + +Singular mode (settings pages) has different hook signatures since there's no entity concept. + +### before_update + +Receives current data and new data: + +```php +$handler->before_update(function(array $current, array $data) { + // Detect changes + if ($current['api_key'] !== $data['api_key']) { + // API key changed, invalidate tokens + delete_transient('my_api_token'); + } + + // Clear cache if cache settings changed + if ($current['cache_ttl'] !== $data['cache_ttl']) { + wp_cache_flush(); + } + + return $data; +}); +``` + +### after_update + +Receives the updated data: + +```php +$handler->after_update(function(array $data) { + // Log settings change + if ($data['debug_mode']) { + error_log('Plugin settings updated: ' . json_encode($data)); + } + + // Trigger action for other plugins + do_action('my_plugin_settings_updated', $data); +}); +``` + +## Chaining Hooks + +Hooks can be chained for cleaner code: + +```php +$handler + ->before_create(function($data) { + $data['created_at'] = current_time('mysql'); + return $data; + }) + ->after_create(function($entity) { + delete_transient('items_list'); + }) + ->before_update(function($entity, $data) { + $data['updated_at'] = current_time('mysql'); + return $data; + }) + ->after_update(function($entity) { + delete_transient('item_' . $entity->get_id()); + }); +``` + +## Common Patterns + +### Auto-generate Slugs + +```php +$handler->before_create(function($data) { + if (empty($data['slug'])) { + $data['slug'] = sanitize_title($data['title']); + } + return $data; +}); +``` + +### Initialize Counters + +```php +$handler->before_create(function($data) { + $data['view_count'] = 0; + $data['like_count'] = 0; + return $data; +}); +``` + +### Send Notifications + +```php +$handler->after_create(function($entity) { + $user = get_user_by('id', $entity->get('user_id')); + if ($user) { + wp_mail( + $user->user_email, + 'Your submission was received', + 'Thank you for submitting ' . $entity->get('title') + ); + } +}); +``` + +### Cascade Deletes + +```php +$handler->after_delete(function($id) { + // Delete child items + $children = get_posts([ + 'post_type' => 'child_item', + 'meta_key' => 'parent_id', + 'meta_value' => $id, + ]); + + foreach ($children as $child) { + wp_delete_post($child->ID, true); + } +}); +``` diff --git a/website/docs/dataview/overview.md b/website/docs/dataview/overview.md new file mode 100644 index 0000000..63dfb84 --- /dev/null +++ b/website/docs/dataview/overview.md @@ -0,0 +1,145 @@ +--- +sidebar_position: 1 +title: Overview +description: Understanding the DataView high-level API +--- + +# DataView Overview + +The DataView layer is a high-level facade that orchestrates all Tangible Object components to provide a simple, declarative API for creating WordPress admin interfaces. + +## What DataView Does + +Instead of manually wiring together DataSet, EditorLayout, Renderer, and RequestHandler, DataView lets you define everything in a single configuration: + +```php +use Tangible\DataView\DataView; + +$view = new DataView([ + 'slug' => 'book', + 'label' => 'Book', + 'fields' => [ + 'title' => 'string', + 'author' => 'string', + 'isbn' => 'string', + 'published' => 'date', + 'in_stock' => 'boolean', + ], + 'ui' => [ + 'menu_label' => 'Books', + 'icon' => 'dashicons-book', + ], +]); + +$view->register(); +``` + +This single declaration: +- Creates a DataSet with the specified fields +- Sets up the appropriate storage adapter (CPT by default) +- Creates a request handler with proper sanitization +- Registers an admin menu page +- Handles all CRUD operations with forms and validation + +## Modes + +DataView supports two modes: + +### Plural Mode (Default) + +For managing multiple items with full CRUD operations: + +```php +$view = new DataView([ + 'slug' => 'product', + 'label' => 'Product', + 'mode' => 'plural', // This is the default + 'fields' => [...], +]); +``` + +Provides: +- List view with all items +- Create form +- Edit form +- Delete action + +### Singular Mode + +For single-instance data like plugin settings: + +```php +$view = new DataView([ + 'slug' => 'my_plugin_settings', + 'label' => 'Settings', + 'mode' => 'singular', + 'storage' => 'option', + 'fields' => [...], +]); +``` + +Provides: +- Single form for reading/updating settings +- No create/delete operations + +## Accessing Components + +After creating a DataView, you can access the underlying components: + +```php +$view = new DataView([...]); + +// Get the request handler for validation and hooks +$handler = $view->get_handler(); + +// Get the data object +$object = $view->get_object(); + +// Get the dataset +$dataset = $view->get_dataset(); + +// Get the configuration +$config = $view->get_config(); + +// Get the field registry +$registry = $view->get_field_registry(); +``` + +## Programmatic Usage + +DataView can be used outside the admin context for programmatic data access: + +```php +$view = new DataView([ + 'slug' => 'subscriber', + 'label' => 'Subscriber', + 'fields' => [ + 'email' => 'email', + 'subscribed' => 'boolean', + ], +]); + +$handler = $view->get_handler(); + +// Create +$result = $handler->create([ + 'email' => 'user@example.com', + 'subscribed' => true, +]); + +// Read +$result = $handler->read($id); +$entity = $result->get_entity(); + +// Update +$handler->update($id, ['subscribed' => false]); + +// Delete +$handler->delete($id); + +// List all +$result = $handler->list(); +foreach ($result->get_entities() as $entity) { + echo $entity->get('email'); +} +``` diff --git a/website/docs/dataview/storage.md b/website/docs/dataview/storage.md new file mode 100644 index 0000000..711f7a3 --- /dev/null +++ b/website/docs/dataview/storage.md @@ -0,0 +1,145 @@ +--- +sidebar_position: 4 +title: Storage +description: Storage backends for DataView +--- + +# Storage Options + +DataView supports multiple storage backends to suit different use cases. + +## Custom Post Type (Default) + +```php +'storage' => 'cpt', +``` + +Uses WordPress Custom Post Types. Best for: +- Integration with existing WordPress workflows +- Content that benefits from post features (revisions, author, etc.) +- Compatibility with WordPress admin features + +**Limitations:** +- CPT slugs must be 20 characters or less +- Uses post meta for field storage + +**Storage options:** + +```php +'storage_options' => [ + 'public' => false, // Hide from frontend + 'show_in_rest' => true, // Enable REST API + 'supports' => ['title'], // Post type supports +], +``` + +## Database + +```php +'storage' => 'database', +``` + +Uses custom database tables via the Database Module. Best for: +- High-volume data +- Complex queries +- Data that doesn't fit the post model +- Better performance for large datasets + +**Storage options:** + +```php +'storage_options' => [ + 'version' => 1, // Increment when schema changes +], +``` + +The database schema is auto-generated from field definitions: + +| Field Type | Database Column | +|------------|-----------------| +| `string` | VARCHAR(255) | +| `text` | TEXT | +| `email` | VARCHAR(255) | +| `url` | VARCHAR(512) | +| `integer` | INT(11) | +| `boolean` | TINYINT(1) | +| `date` | DATE | +| `datetime` | DATETIME | +| `repeater` | LONGTEXT | + +## Option + +```php +'storage' => 'option', +``` + +Uses WordPress options. Best for: +- Singular mode (settings pages) +- Single-instance data +- Simple key-value storage + +**Note:** This storage type is typically used with `'mode' => 'singular'`. + +```php +$view = new DataView([ + 'slug' => 'my_plugin_settings', + 'label' => 'Settings', + 'storage' => 'option', + 'mode' => 'singular', + 'fields' => [ + 'api_key' => 'string', + 'debug_mode' => 'boolean', + ], +]); +``` + +## Choosing a Storage Backend + +| Use Case | Recommended Storage | +|----------|---------------------| +| Plugin settings | `option` + `singular` mode | +| Content-like data (posts, pages) | `cpt` | +| High-volume transactional data | `database` | +| Data needing WordPress features | `cpt` | +| Custom queries and joins | `database` | +| Simple CRUD with few records | `cpt` | + +## Example: Settings Page + +```php +$view = new DataView([ + 'slug' => 'my_plugin_settings', + 'label' => 'Settings', + 'storage' => 'option', + 'mode' => 'singular', + 'fields' => [ + 'api_key' => 'string', + 'api_url' => 'url', + 'cache_ttl' => 'integer', + 'debug_mode' => 'boolean', + ], + 'ui' => [ + 'menu_label' => 'My Plugin', + 'parent' => 'options-general.php', + ], +]); +``` + +## Example: High-Volume Data + +```php +$view = new DataView([ + 'slug' => 'analytics_event', + 'label' => 'Event', + 'storage' => 'database', + 'storage_options' => [ + 'version' => 1, + ], + 'fields' => [ + 'event_type' => 'string', + 'user_id' => 'integer', + 'data' => 'text', + 'created_at' => 'datetime', + ], +]); +``` diff --git a/website/docs/dataview/validation.md b/website/docs/dataview/validation.md new file mode 100644 index 0000000..95110b5 --- /dev/null +++ b/website/docs/dataview/validation.md @@ -0,0 +1,153 @@ +--- +sidebar_position: 5 +title: Validation +description: Validating data with built-in and custom validators +--- + +# Validation + +DataView provides a flexible validation system through the request handler. + +## Adding Validators + +Access the handler and add validators: + +```php +use Tangible\RequestHandler\Validators; + +$view = new DataView([...]); + +$handler = $view->get_handler(); + +$handler + ->add_validator('title', Validators::required()) + ->add_validator('email', Validators::email()); +``` + +Multiple validators can be added to the same field: + +```php +$handler + ->add_validator('email', Validators::required()) + ->add_validator('email', Validators::email()); +``` + +## Built-in Validators + +### Required + +Ensures the field has a non-empty value: + +```php +$handler->add_validator('name', Validators::required()); +``` + +### Email + +Validates email format: + +```php +$handler->add_validator('email', Validators::email()); +``` + +### String Length + +```php +$handler->add_validator('username', Validators::min_length(3)); +$handler->add_validator('username', Validators::max_length(20)); +``` + +### Numeric Range + +```php +$handler->add_validator('age', Validators::min(0)); +$handler->add_validator('age', Validators::max(120)); +``` + +### Allowed Values + +Ensures the value is one of the allowed options: + +```php +$handler->add_validator('status', Validators::in(['draft', 'published', 'archived'])); +``` + +## Custom Validators + +Create custom validation logic by passing a callable: + +```php +use Tangible\RequestHandler\ValidationError; + +$handler->add_validator('slug', function($value) { + if (!preg_match('/^[a-z0-9-]+$/', $value)) { + return new ValidationError( + 'Slug can only contain lowercase letters, numbers, and hyphens' + ); + } + return true; +}); +``` + +### Validator Signature + +Custom validators receive: +- `$value` - The field value being validated + +They should return: +- `true` - Validation passed +- `ValidationError` - Validation failed with a message + +### Example: Unique Value + +```php +$handler->add_validator('email', function($value) use ($view) { + // Check if email already exists + $existing = $view->get_handler()->list(); + foreach ($existing->get_entities() as $entity) { + if ($entity->get('email') === $value) { + return new ValidationError('This email is already registered'); + } + } + return true; +}); +``` + +## Validation Errors + +When validation fails, errors are available on the result: + +```php +$result = $handler->create([ + 'name' => '', // Required field is empty + 'email' => 'invalid', // Invalid email format +]); + +if ($result->is_error()) { + $errors = $result->get_errors(); + + foreach ($errors as $error) { + echo $error->get_field() . ': ' . $error->get_message(); + } +} +``` + +In the admin UI, validation errors are automatically displayed above the form. + +## Validation Order + +Validators run in the order they were added. If a validator fails, subsequent validators for that field still run, and all errors are collected. + +```php +$handler + ->add_validator('password', Validators::required()) + ->add_validator('password', Validators::min_length(8)) + ->add_validator('password', function($value) { + if (!preg_match('/[A-Z]/', $value)) { + return new ValidationError('Must contain an uppercase letter'); + } + return true; + }); +``` + +All three validators run, and all failing validators contribute to the error list. diff --git a/website/docs/examples/crud-admin.md b/website/docs/examples/crud-admin.md new file mode 100644 index 0000000..ae07353 --- /dev/null +++ b/website/docs/examples/crud-admin.md @@ -0,0 +1,249 @@ +--- +sidebar_position: 2 +title: CRUD Admin +description: Building a full CRUD admin interface +--- + +# CRUD Admin Example + +This example shows how to create a complete admin interface for managing data with create, read, update, and delete operations. + +## Basic CRUD + +```php +use Tangible\DataView\DataView; +use Tangible\RequestHandler\Validators; + +add_action('admin_menu', function() { + $view = new DataView([ + 'slug' => 'contact_entry', + 'label' => [ + 'singular' => 'Contact Entry', + 'plural' => 'Contact Entries', + ], + 'fields' => [ + 'name' => 'string', + 'email' => 'email', + 'message' => 'text', + 'subscribe' => 'boolean', + ], + 'ui' => [ + 'menu_label' => 'Contact Entries', + 'icon' => 'dashicons-email', + ], + ]); + + $view->get_handler() + ->add_validator('name', Validators::required()) + ->add_validator('email', Validators::required()) + ->add_validator('email', Validators::email()); + + $view->register(); +}); +``` + +## Complete Blog Post Manager + +A more complex example with custom layout, validation, and hooks: + +```php +use Tangible\DataView\DataView; +use Tangible\RequestHandler\Validators; +use Tangible\EditorLayout\Layout; +use Tangible\EditorLayout\Section; +use Tangible\EditorLayout\Sidebar; +use Tangible\EditorLayout\Tabs; +use Tangible\EditorLayout\Tab; + +add_action('admin_menu', function() { + $view = new DataView([ + 'slug' => 'blog_post', + 'label' => [ + 'singular' => __('Blog Post', 'my-theme'), + 'plural' => __('Blog Posts', 'my-theme'), + 'add_new_item' => __('Write New Post', 'my-theme'), + 'edit_item' => __('Edit Post', 'my-theme'), + ], + 'fields' => [ + 'title' => 'string', + 'slug' => 'string', + 'content' => 'text', + 'excerpt' => 'text', + 'author_email' => 'email', + 'published_at' => 'datetime', + 'is_featured' => 'boolean', + 'view_count' => 'integer', + ], + 'ui' => [ + 'menu_label' => __('Blog Posts', 'my-theme'), + 'icon' => 'dashicons-edit', + 'position' => 5, + ], + ]); + + // Custom layout with tabs + $view->set_layout(function(Layout $layout) { + $layout->tabs(function(Tabs $tabs) { + $tabs->tab('Content', function(Tab $tab) { + $tab->field('title') + ->placeholder('Post title') + ->help('The main title of the post'); + $tab->field('slug') + ->placeholder('post-url-slug') + ->help('URL-friendly identifier'); + $tab->field('content') + ->help('Main post content'); + $tab->field('excerpt') + ->help('Short summary for listings'); + }); + + $tabs->tab('Meta', function(Tab $tab) { + $tab->field('author_email'); + $tab->field('published_at'); + $tab->field('view_count')->readonly(); + }); + }); + + $layout->sidebar(function(Sidebar $sidebar) { + $sidebar->field('is_featured'); + $sidebar->actions(['save', 'delete']); + }); + }); + + // Validation + $view->get_handler() + ->add_validator('title', Validators::required()) + ->add_validator('title', Validators::max_length(200)) + ->add_validator('slug', Validators::required()) + ->add_validator('author_email', Validators::email()) + ->add_validator('slug', function($value) { + if (!preg_match('/^[a-z0-9-]+$/', $value)) { + return new \Tangible\RequestHandler\ValidationError( + 'Slug must contain only lowercase letters, numbers, and hyphens' + ); + } + return true; + }); + + // Lifecycle hooks + $view->get_handler() + ->before_create(function($data) { + $data['view_count'] = 0; + if (empty($data['slug'])) { + $data['slug'] = sanitize_title($data['title']); + } + return $data; + }) + ->after_create(function($entity) { + delete_transient('blog_posts_list'); + }) + ->before_update(function($entity, $data) { + // Auto-generate slug if empty + if (empty($data['slug']) && !empty($data['title'])) { + $data['slug'] = sanitize_title($data['title']); + } + return $data; + }) + ->after_update(function($entity) { + delete_transient('blog_post_' . $entity->get_id()); + delete_transient('blog_posts_list'); + }) + ->after_delete(function($id) { + delete_transient('blog_post_' . $id); + delete_transient('blog_posts_list'); + }); + + $view->register(); +}); +``` + +## Product Catalog + +An e-commerce product example: + +```php +add_action('admin_menu', function() { + $view = new DataView([ + 'slug' => 'product', + 'label' => [ + 'singular' => 'Product', + 'plural' => 'Products', + ], + 'fields' => [ + 'name' => 'string', + 'sku' => 'string', + 'description' => 'text', + 'price' => 'integer', + 'sale_price' => 'integer', + 'stock_quantity' => 'integer', + 'is_active' => 'boolean', + 'is_featured' => 'boolean', + ], + 'ui' => [ + 'menu_label' => 'Products', + 'icon' => 'dashicons-cart', + ], + ]); + + $view->set_layout(function(Layout $layout) { + $layout->section('Product Information', function(Section $s) { + $s->field('name') + ->placeholder('Product name'); + $s->field('sku') + ->placeholder('SKU-001') + ->help('Stock Keeping Unit'); + $s->field('description'); + }); + + $layout->section('Pricing', function(Section $s) { + $s->columns(2); + $s->field('price') + ->help('Regular price in cents'); + $s->field('sale_price') + ->help('Sale price in cents (leave empty for no sale)'); + }); + + $layout->section('Inventory', function(Section $s) { + $s->field('stock_quantity'); + }); + + $layout->sidebar(function(Sidebar $sb) { + $sb->field('is_active') + ->help('Show on storefront'); + $sb->field('is_featured') + ->help('Display on homepage'); + $sb->actions(['save', 'delete']); + }); + }); + + $view->get_handler() + ->add_validator('name', Validators::required()) + ->add_validator('sku', Validators::required()) + ->add_validator('price', Validators::required()) + ->add_validator('price', Validators::min(0)) + ->add_validator('stock_quantity', Validators::min(0)); + + $view->register(); +}); +``` + +## Submenu Under CPT + +Add a related data manager under a custom post type: + +```php +$view = new DataView([ + 'slug' => 'product_review', + 'label' => 'Review', + 'fields' => [ + 'product_id' => 'integer', + 'rating' => 'integer', + 'comment' => 'text', + 'approved' => 'boolean', + ], + 'ui' => [ + 'menu_label' => 'Reviews', + 'parent' => 'edit.php?post_type=product', + ], +]); +``` diff --git a/website/docs/examples/invoice-manager.md b/website/docs/examples/invoice-manager.md new file mode 100644 index 0000000..fc5154e --- /dev/null +++ b/website/docs/examples/invoice-manager.md @@ -0,0 +1,316 @@ +--- +sidebar_position: 3 +title: Invoice Manager +description: Complex example with repeater fields +--- + +# Invoice Manager Example + +This example demonstrates a complete invoice management system using TangibleFieldsRenderer with repeater fields for line items. + +## Complete Implementation + +```php +use Tangible\DataView\DataView; +use Tangible\Renderer\TangibleFieldsRenderer; +use Tangible\RequestHandler\Validators; +use Tangible\EditorLayout\Layout; +use Tangible\EditorLayout\Section; +use Tangible\EditorLayout\Sidebar; + +add_action('admin_menu', function() { + $view = new DataView([ + 'slug' => 'invoice', + 'label' => [ + 'singular' => __('Invoice', 'my-plugin'), + 'plural' => __('Invoices', 'my-plugin'), + ], + 'fields' => [ + // Customer info + 'customer_name' => [ + 'type' => 'string', + 'label' => __('Customer Name', 'my-plugin'), + 'placeholder' => 'Enter customer name', + ], + 'customer_email' => [ + 'type' => 'email', + 'label' => __('Customer Email', 'my-plugin'), + 'placeholder' => 'customer@example.com', + ], + + // Invoice details + 'invoice_date' => [ + 'type' => 'date', + 'label' => __('Invoice Date', 'my-plugin'), + ], + 'due_date' => [ + 'type' => 'date', + 'label' => __('Due Date', 'my-plugin'), + ], + + // Line items (repeater) + 'line_items' => [ + 'type' => 'repeater', + 'label' => __('Line Items', 'my-plugin'), + 'description' => __('Add products or services to this invoice', 'my-plugin'), + 'layout' => 'table', + 'min_rows' => 1, + 'max_rows' => 100, + 'button_label' => __('Add Line Item', 'my-plugin'), + 'sub_fields' => [ + [ + 'name' => 'description', + 'type' => 'string', + 'label' => __('Description', 'my-plugin'), + 'placeholder' => 'Product or service', + ], + [ + 'name' => 'quantity', + 'type' => 'integer', + 'label' => __('Qty', 'my-plugin'), + 'min' => 1, + ], + [ + 'name' => 'unit_price', + 'type' => 'integer', + 'label' => __('Unit Price (cents)', 'my-plugin'), + 'min' => 0, + ], + [ + 'name' => 'taxable', + 'type' => 'boolean', + 'label' => __('Tax', 'my-plugin'), + ], + ], + 'default' => [ + ['description' => '', 'quantity' => 1, 'unit_price' => 0, 'taxable' => true], + ], + ], + + // Notes + 'notes' => [ + 'type' => 'text', + 'label' => __('Notes', 'my-plugin'), + 'rows' => 4, + 'description' => 'Additional notes to include on the invoice', + ], + + // Calculated fields (read-only in UI) + 'subtotal' => 'integer', + 'tax_total' => 'integer', + 'total' => 'integer', + + // Status + 'is_paid' => [ + 'type' => 'boolean', + 'label' => __('Paid', 'my-plugin'), + ], + ], + 'ui' => [ + 'menu_label' => __('Invoices', 'my-plugin'), + 'icon' => 'dashicons-media-spreadsheet', + ], + ]); + + // Use TangibleFieldsRenderer for rich UI + $view->set_renderer(new TangibleFieldsRenderer()); + + // Custom layout + $view->set_layout(function(Layout $layout) { + $layout->section('Customer Information', function(Section $s) { + $s->columns(2); + $s->field('customer_name'); + $s->field('customer_email'); + }); + + $layout->section('Invoice Details', function(Section $s) { + $s->columns(2); + $s->field('invoice_date'); + $s->field('due_date'); + }); + + $layout->section('Line Items', function(Section $s) { + $s->field('line_items'); + }); + + $layout->section('Summary', function(Section $s) { + $s->columns(3); + $s->field('subtotal')->readonly(); + $s->field('tax_total')->readonly(); + $s->field('total')->readonly(); + }); + + $layout->section('Additional Info', function(Section $s) { + $s->field('notes'); + }); + + $layout->sidebar(function(Sidebar $sb) { + $sb->field('is_paid'); + $sb->actions(['save', 'delete']); + }); + }); + + // Validation + $view->get_handler() + ->add_validator('customer_name', Validators::required()) + ->add_validator('customer_email', Validators::required()) + ->add_validator('customer_email', Validators::email()) + ->add_validator('invoice_date', Validators::required()); + + // Calculate totals before saving + $view->get_handler() + ->before_create(function($data) { + return calculate_invoice_totals($data); + }) + ->before_update(function($entity, $data) { + return calculate_invoice_totals($data); + }); + + $view->register(); +}); + +/** + * Calculate invoice totals from line items. + */ +function calculate_invoice_totals(array $data): array { + $line_items = json_decode($data['line_items'] ?? '[]', true); + $subtotal = 0; + $tax_total = 0; + $tax_rate = 0.08; // 8% tax rate + + foreach ($line_items as $item) { + $line_total = ($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0); + $subtotal += $line_total; + + if (!empty($item['taxable'])) { + $tax_total += (int) round($line_total * $tax_rate); + } + } + + $data['subtotal'] = $subtotal; + $data['tax_total'] = $tax_total; + $data['total'] = $subtotal + $tax_total; + + return $data; +} +``` + +## Display Invoice on Frontend + +```php +function display_invoice($invoice_id) { + // Get the DataView handler + $view = get_invoice_dataview(); + $result = $view->get_handler()->read($invoice_id); + + if ($result->is_error()) { + return '

Invoice not found.

'; + } + + $entity = $result->get_entity(); + $line_items = json_decode($entity->get('line_items'), true); + + ob_start(); + ?> +
+

Invoice

+ +
+ get('customer_name')); ?>
+ get('customer_email')); ?> +
+ +
+

Invoice Date: get('invoice_date')); ?>

+

Due Date: get('due_date')); ?>

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DescriptionQtyUnit PriceTotal
Subtotalget('subtotal')); ?>
Taxget('tax_total')); ?>
Totalget('total')); ?>
+ + get('notes')): ?> +
+

Notes

+

get('notes')); ?>

+
+ + +
+ get('is_paid')): ?> + + + UNPAID + +
+
+ 'faq', + 'label' => 'FAQ', + 'fields' => [ + 'title' => 'string', + 'questions' => [ + 'type' => 'repeater', + 'layout' => 'block', + 'sub_fields' => [ + ['name' => 'question', 'type' => 'string', 'label' => 'Question'], + ['name' => 'answer', 'type' => 'string', 'label' => 'Answer'], + ], + ], + ], + ]); + + $view->set_renderer(new TangibleFieldsRenderer()); + $view->register(); +}); +``` diff --git a/website/docs/examples/settings-page.md b/website/docs/examples/settings-page.md new file mode 100644 index 0000000..f8f9da9 --- /dev/null +++ b/website/docs/examples/settings-page.md @@ -0,0 +1,200 @@ +--- +sidebar_position: 1 +title: Settings Page +description: Building a plugin settings page +--- + +# Settings Page Example + +This example shows how to create a comprehensive plugin settings page with multiple sections. + +## Basic Settings + +```php +use Tangible\DataView\DataView; + +add_action('admin_menu', function() { + $view = new DataView([ + 'slug' => 'my_plugin_settings', + 'label' => 'Settings', + 'fields' => [ + 'api_key' => 'string', + 'debug_mode' => 'boolean', + ], + 'storage' => 'option', + 'mode' => 'singular', + 'ui' => [ + 'menu_label' => 'My Plugin', + 'parent' => 'options-general.php', + ], + ]); + + $view->register(); +}); +``` + +## Complete Settings Page + +A full-featured settings page with sections and validation: + +```php +use Tangible\DataView\DataView; +use Tangible\RequestHandler\Validators; +use Tangible\EditorLayout\Layout; +use Tangible\EditorLayout\Section; +use Tangible\EditorLayout\Sidebar; + +add_action('admin_menu', function() { + $view = new DataView([ + 'slug' => 'my_plugin_settings', + 'label' => [ + 'singular' => __('Settings', 'my-plugin'), + 'settings' => __('My Plugin Settings', 'my-plugin'), + 'settings_saved' => __('Settings saved successfully.', 'my-plugin'), + ], + 'fields' => [ + // API Settings + 'api_key' => 'string', + 'api_endpoint' => 'url', + 'api_timeout' => 'integer', + + // Cache Settings + 'cache_enabled' => 'boolean', + 'cache_ttl' => 'integer', + + // Display Settings + 'items_per_page' => 'integer', + 'date_format' => 'string', + + // Debug + 'debug_mode' => 'boolean', + 'log_level' => 'string', + ], + 'storage' => 'option', + 'mode' => 'singular', + 'ui' => [ + 'menu_label' => __('My Plugin', 'my-plugin'), + 'parent' => 'options-general.php', + ], + ]); + + // Custom layout with sections + $view->set_layout(function(Layout $layout) { + $layout->section(__('API Configuration', 'my-plugin'), function(Section $s) { + $s->field('api_key') + ->placeholder('Enter your API key') + ->help('Get your API key from the dashboard'); + $s->field('api_endpoint') + ->placeholder('https://api.example.com/v1'); + $s->field('api_timeout') + ->help('Request timeout in seconds'); + }); + + $layout->section(__('Caching', 'my-plugin'), function(Section $s) { + $s->field('cache_enabled') + ->help('Enable response caching'); + $s->field('cache_ttl') + ->help('Cache time-to-live in seconds'); + }); + + $layout->section(__('Display', 'my-plugin'), function(Section $s) { + $s->field('items_per_page'); + $s->field('date_format') + ->placeholder('Y-m-d H:i:s'); + }); + + $layout->section(__('Development', 'my-plugin'), function(Section $s) { + $s->field('debug_mode') + ->help('Enable detailed logging'); + $s->field('log_level') + ->help('Options: debug, info, warning, error'); + }); + + $layout->sidebar(function(Sidebar $sb) { + $sb->actions(['save']); + }); + }); + + // Validation + $view->get_handler() + ->add_validator('api_key', Validators::required()) + ->add_validator('api_timeout', Validators::min(1)) + ->add_validator('api_timeout', Validators::max(60)) + ->add_validator('cache_ttl', Validators::min(0)) + ->add_validator('items_per_page', Validators::min(1)) + ->add_validator('items_per_page', Validators::max(100)) + ->add_validator('log_level', Validators::in(['debug', 'info', 'warning', 'error'])); + + // Clear cache when settings change + $view->get_handler() + ->before_update(function($current, $data) { + if (($current['cache_ttl'] ?? 0) !== ($data['cache_ttl'] ?? 0) || + ($current['cache_enabled'] ?? false) !== ($data['cache_enabled'] ?? false)) { + wp_cache_flush(); + } + return $data; + }); + + $view->register(); +}); +``` + +## Accessing Settings Elsewhere + +Read settings values in your plugin: + +```php +function my_plugin_get_setting($key, $default = null) { + $settings = get_option('my_plugin_settings', []); + return $settings[$key] ?? $default; +} + +// Usage +$api_key = my_plugin_get_setting('api_key'); +$debug = my_plugin_get_setting('debug_mode', false); +``` + +## Settings with Tabs + +For many settings, use tabs to organize: + +```php +use Tangible\EditorLayout\Tabs; +use Tangible\EditorLayout\Tab; + +$view->set_layout(function(Layout $layout) { + $layout->tabs(function(Tabs $tabs) { + $tabs->tab('General', function(Tab $t) { + $t->field('site_title'); + $t->field('tagline'); + }); + + $tabs->tab('API', function(Tab $t) { + $t->field('api_key'); + $t->field('api_endpoint'); + }); + + $tabs->tab('Advanced', function(Tab $t) { + $t->field('debug_mode'); + $t->field('log_level'); + }); + }); + + $layout->sidebar(function(Sidebar $sb) { + $sb->actions(['save']); + }); +}); +``` + +## Top-Level Menu + +For a top-level settings menu instead of under Settings: + +```php +'ui' => [ + 'menu_label' => 'My Plugin', + 'icon' => 'dashicons-admin-generic', + 'position' => 80, + // No 'parent' key = top-level menu +] +``` diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md new file mode 100644 index 0000000..3c378ba --- /dev/null +++ b/website/docs/getting-started/installation.md @@ -0,0 +1,55 @@ +--- +sidebar_position: 1 +title: Installation +description: How to install Tangible Object in your WordPress project +--- + +# Installation + +## Via Composer + +The recommended way to install Tangible Object is via Composer: + +```bash +composer require tangible/object +``` + +Then include the autoloader in your plugin or theme: + +```php +require_once __DIR__ . '/vendor/autoload.php'; +``` + +## Manual Installation + +1. Download the latest release from the [GitHub repository](https://github.com/tangibleinc/object) +2. Extract to your plugin's directory +3. Include the main file: + +```php +require_once __DIR__ . '/path/to/object/plugin.php'; +``` + +## Verifying Installation + +To verify the installation is working, you can create a simple DataView: + +```php +use Tangible\DataView\DataView; + +add_action('admin_menu', function() { + $view = new DataView([ + 'slug' => 'test_item', + 'label' => 'Test', + 'fields' => [ + 'title' => 'string', + ], + 'storage' => 'option', + 'mode' => 'singular', + ]); + + $view->register(); +}); +``` + +After activating your plugin, you should see a "Test" menu item in the WordPress admin. diff --git a/website/docs/getting-started/quick-start.md b/website/docs/getting-started/quick-start.md new file mode 100644 index 0000000..689f5a5 --- /dev/null +++ b/website/docs/getting-started/quick-start.md @@ -0,0 +1,80 @@ +--- +sidebar_position: 2 +title: Quick Start +description: Create your first admin interface in minutes +--- + +# Quick Start + +This guide will walk you through creating a complete admin interface for managing contact form entries. + +## Step 1: Define Your DataView + +Create a new PHP file in your plugin (e.g., `includes/contact-entries.php`): + +```php + 'contact_entry', + 'label' => 'Contact Entry', + 'fields' => [ + 'name' => 'string', + 'email' => 'email', + 'message' => 'text', + 'subscribe' => 'boolean', + ], + 'ui' => [ + 'menu_label' => 'Contact Entries', + 'icon' => 'dashicons-email', + ], + ]); + + // Add validation + $view->get_handler() + ->add_validator('name', Validators::required()) + ->add_validator('email', Validators::required()) + ->add_validator('email', Validators::email()); + + $view->register(); +}); +``` + +## Step 2: Include in Your Plugin + +Add the file to your main plugin file: + +```php +require_once __DIR__ . '/includes/contact-entries.php'; +``` + +## Step 3: That's It! + +Visit your WordPress admin. You'll see a new "Contact Entries" menu item with: + +- A **list view** showing all entries +- A **create form** for adding new entries +- An **edit form** for updating entries +- **Delete** functionality +- **Validation** that enforces required fields and valid email format + +## What Just Happened? + +With about 20 lines of code, DataView: + +1. Registered a Custom Post Type for your data +2. Set up automatic type coercion (strings, booleans, etc.) +3. Generated a responsive admin menu page +4. Built list, create, and edit views with proper WordPress styling +5. Added form handling with sanitization and validation +6. Implemented full CRUD operations + +## Next Steps + +- [Configure field types](/dataview/field-types) for more control +- [Add custom layouts](/layouts/overview) with sections and tabs +- [Set up lifecycle hooks](/dataview/lifecycle-hooks) for custom logic +- [Use different storage backends](/dataview/storage) like CPT or options diff --git a/website/docs/intro.md b/website/docs/intro.md new file mode 100644 index 0000000..2fa9cdd --- /dev/null +++ b/website/docs/intro.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +slug: / +title: Introduction +description: A WordPress tool suite for building data-driven admin interfaces +--- + +# Tangible Object + +A WordPress tool suite for building data-driven admin interfaces with a clean, layered architecture. + +## What is Tangible Object? + +Tangible Object provides a structured approach to building WordPress admin interfaces. Whether you need a simple settings page or a full CRUD interface for custom data, this framework handles the complexity while keeping your code clean and maintainable. + +## Two Ways to Build + +### The Easy Way: DataView + +For most use cases, **DataView** is all you need. It's a high-level API that lets you define your entire admin interface in a single configuration array: + +```php +use Tangible\DataView\DataView; + +$view = new DataView([ + 'slug' => 'contact_entry', + 'label' => 'Contact', + 'fields' => [ + 'name' => 'string', + 'email' => 'email', + 'message' => 'text', + ], + 'ui' => [ + 'menu_label' => 'Contact Entries', + ], +]); + +$view->register(); +``` + +This single declaration creates: +- A Custom Post Type to store your data +- An admin menu page +- List, create, and edit views +- Form handling with validation +- Proper sanitization + +[Get started with DataView →](/getting-started/quick-start) + +### The Flexible Way: Four-Layer Architecture + +For advanced customization, you can work directly with the four underlying layers: + +1. **DataSet** - Define field types and coercion rules +2. **EditorLayout** - Compose the editor structure (sections, tabs, fields) +3. **Renderer** - Generate HTML output from the layout +4. **RequestHandler** - Handle CRUD operations with validation + +[Learn about the architecture →](/advanced/architecture) + +## Key Features + +- **Declarative configuration** - Define your data structure once, get forms and validation automatically +- **Multiple storage backends** - Custom Post Types, database tables, or WordPress options +- **Flexible layouts** - Sections, tabs, sidebars, and nested structures +- **Built-in validation** - Required fields, email, min/max, custom validators +- **Lifecycle hooks** - React to create, update, and delete operations +- **Repeater fields** - Manage collections of sub-items +- **Multiple renderers** - Plain HTML or rich Tangible Fields components + +## Requirements + +- PHP 8.0+ +- WordPress 5.0+ diff --git a/website/docs/layouts/custom-layouts.md b/website/docs/layouts/custom-layouts.md new file mode 100644 index 0000000..436bbfd --- /dev/null +++ b/website/docs/layouts/custom-layouts.md @@ -0,0 +1,196 @@ +--- +sidebar_position: 5 +title: Custom Layouts +description: Advanced layout customization techniques +--- + +# Custom Layouts + +This page covers advanced layout patterns and techniques. + +## Layout Structure + +When you call `set_layout()`, you're building a structure that the renderer will convert to HTML: + +```php +$view->set_layout(function(Layout $layout) { + // Build your structure here +}); +``` + +The layout object tracks: +- **Items** - Sections, tabs, and fields in the main area +- **Sidebar** - Sidebar configuration (optional) +- **Dataset** - Reference to the DataSet for field type info + +## Accessing the Structure + +You can inspect the layout structure: + +```php +$structure = $layout->get_structure(); + +// Returns: +// [ +// 'items' => [...], +// 'sidebar' => [...], +// ] +``` + +This is useful when building custom renderers. + +## Conditional Sections + +Show sections based on field values: + +```php +$layout->section('Basic', function(Section $s) { + $s->field('product_type'); // 'physical' or 'digital' +}); + +$layout->section('Shipping', function(Section $s) { + $s->condition('product_type', 'physical'); + + $s->field('weight'); + $s->field('dimensions'); +}); + +$layout->section('Download', function(Section $s) { + $s->condition('product_type', 'digital'); + + $s->field('download_url'); + $s->field('download_limit'); +}); +``` + +## Dynamic Layout Building + +Build layouts programmatically: + +```php +$view->set_layout(function(Layout $layout) use ($custom_fields) { + // Standard fields + $layout->section('Basic', function(Section $s) { + $s->field('title'); + $s->field('description'); + }); + + // Dynamic fields from configuration + if (!empty($custom_fields)) { + $layout->section('Custom Fields', function(Section $s) use ($custom_fields) { + foreach ($custom_fields as $field) { + $s->field($field['name']) + ->help($field['description'] ?? ''); + } + }); + } + + $layout->sidebar(function(Sidebar $sb) { + $sb->actions(['save', 'delete']); + }); +}); +``` + +## Reusable Layout Components + +Create reusable layout functions: + +```php +function add_seo_section(Layout $layout) { + $layout->section('SEO', function(Section $s) { + $s->field('meta_title') + ->placeholder('Page title for search engines'); + $s->field('meta_description') + ->help('Max 160 characters'); + $s->field('meta_keywords'); + }); +} + +function add_status_sidebar(Layout $layout) { + $layout->sidebar(function(Sidebar $sb) { + $sb->field('status'); + $sb->field('publish_date'); + $sb->actions(['save', 'delete']); + }); +} + +// Use in multiple DataViews +$view->set_layout(function(Layout $layout) { + $layout->section('Content', function(Section $s) { + $s->field('title'); + $s->field('body'); + }); + + add_seo_section($layout); + add_status_sidebar($layout); +}); +``` + +## Complex Nesting Example + +A deeply nested layout for complex data: + +```php +$view->set_layout(function(Layout $layout) { + $layout->section('Event', function(Section $s) { + $s->field('title'); + $s->field('description'); + + // Location section + $s->section('Location', function(Section $loc) { + $loc->field('venue_name'); + + $loc->section('Address', function(Section $addr) { + $addr->columns(2); + $addr->field('street'); + $addr->field('city'); + $addr->field('state'); + $addr->field('zip'); + }); + + $loc->section('Coordinates', function(Section $coords) { + $coords->columns(2); + $coords->field('latitude'); + $coords->field('longitude'); + }); + }); + + // Schedule tabs + $s->tabs(function(Tabs $tabs) { + $tabs->tab('Date & Time', function(Tab $t) { + $t->field('start_date'); + $t->field('end_date'); + $t->field('timezone'); + }); + + $tabs->tab('Recurrence', function(Tab $t) { + $t->field('is_recurring'); + $t->field('recurrence_pattern'); + $t->field('recurrence_end'); + }); + }); + }); + + $layout->sidebar(function(Sidebar $sb) { + $sb->field('status'); + $sb->field('is_featured'); + $sb->field('max_attendees'); + $sb->actions(['save', 'delete']); + }); +}); +``` + +## Layout Without Sidebar + +Not all layouts need a sidebar: + +```php +$view->set_layout(function(Layout $layout) { + $layout->section('Settings', function(Section $s) { + $s->field('option1'); + $s->field('option2'); + }); + + // The save button will appear at the bottom of the form +}); +``` diff --git a/website/docs/layouts/overview.md b/website/docs/layouts/overview.md new file mode 100644 index 0000000..90af114 --- /dev/null +++ b/website/docs/layouts/overview.md @@ -0,0 +1,133 @@ +--- +sidebar_position: 1 +title: Overview +description: Introduction to EditorLayout for structuring forms +--- + +# Layouts Overview + +The EditorLayout system lets you structure your forms with sections, tabs, sidebars, and nested layouts. + +## Default Layout + +By default, DataView creates a simple layout with all fields in a single section: + +```php +$view = new DataView([ + 'slug' => 'product', + 'label' => 'Product', + 'fields' => [ + 'title' => 'string', + 'description' => 'text', + 'price' => 'integer', + 'in_stock' => 'boolean', + ], +]); +``` + +This renders all fields in order with auto-generated labels. + +## Custom Layouts + +Use `set_layout()` to define a custom structure: + +```php +use Tangible\EditorLayout\Layout; +use Tangible\EditorLayout\Section; +use Tangible\EditorLayout\Sidebar; + +$view->set_layout(function(Layout $layout) { + $layout->section('Product Details', function(Section $s) { + $s->field('title') + ->placeholder('Product name') + ->help('The display name for this product'); + $s->field('description'); + }); + + $layout->section('Inventory', function(Section $s) { + $s->field('price'); + $s->field('in_stock'); + }); + + $layout->sidebar(function(Sidebar $sb) { + $sb->actions(['save', 'delete']); + }); +}); +``` + +## Layout Components + +### Sections + +Group related fields together: + +```php +$layout->section('Section Title', function(Section $s) { + $s->field('field_name'); +}); +``` + +### Tabs + +Organize content into tabbed panels: + +```php +$layout->tabs(function(Tabs $tabs) { + $tabs->tab('Tab 1', function(Tab $t) { + $t->field('field1'); + }); + $tabs->tab('Tab 2', function(Tab $t) { + $t->field('field2'); + }); +}); +``` + +### Sidebar + +Add a sidebar for status fields and actions: + +```php +$layout->sidebar(function(Sidebar $sb) { + $sb->field('status'); + $sb->actions(['save', 'delete']); +}); +``` + +## Nesting + +Sections and tabs can be nested arbitrarily: + +```php +$layout->section('Main', function(Section $s) { + $s->field('title'); + + // Nested section + $s->section('Advanced', function(Section $nested) { + $nested->field('slug'); + }); + + // Tabs inside section + $s->tabs(function(Tabs $tabs) { + $tabs->tab('Content', function(Tab $t) { + $t->field('body'); + }); + $tabs->tab('SEO', function(Tab $t) { + $t->field('meta_description'); + }); + }); +}); +``` + +## Field Configuration + +Within layouts, you can configure field presentation: + +```php +$s->field('title') + ->placeholder('Enter title') + ->help('Help text shown below') + ->readonly() + ->width('50%'); +``` + +See [Sections](/layouts/sections) for all field options. diff --git a/website/docs/layouts/sections.md b/website/docs/layouts/sections.md new file mode 100644 index 0000000..b018bc6 --- /dev/null +++ b/website/docs/layouts/sections.md @@ -0,0 +1,163 @@ +--- +sidebar_position: 2 +title: Sections +description: Grouping fields into sections +--- + +# Sections + +Sections group related fields under a labeled heading. + +## Basic Section + +```php +use Tangible\EditorLayout\Layout; +use Tangible\EditorLayout\Section; + +$view->set_layout(function(Layout $layout) { + $layout->section('Contact Information', function(Section $s) { + $s->field('name'); + $s->field('email'); + $s->field('phone'); + }); +}); +``` + +## Field Options + +Configure how fields are displayed: + +```php +$s->field('title') + ->placeholder('Enter a title') // Placeholder text + ->help('Help text below field') // Help text + ->readonly() // Make read-only + ->width('50%'); // Set width +``` + +### placeholder + +Sets the input placeholder text: + +```php +$s->field('email')->placeholder('you@example.com'); +``` + +### help + +Displays help text below the field: + +```php +$s->field('slug')->help('URL-friendly identifier, lowercase with hyphens'); +``` + +### readonly + +Makes the field read-only (displayed but not editable): + +```php +$s->field('created_at')->readonly(); +``` + +### width + +Sets the field width (useful for inline fields): + +```php +$s->field('first_name')->width('50%'); +$s->field('last_name')->width('50%'); +``` + +## Section Options + +### columns + +Display fields in multiple columns: + +```php +$layout->section('Address', function(Section $s) { + $s->columns(2); + + $s->field('street'); + $s->field('city'); + $s->field('state'); + $s->field('zip'); +}); +``` + +### condition + +Show section conditionally based on another field's value: + +```php +$layout->section('Shipping Address', function(Section $s) { + $s->condition('needs_shipping', true); + + $s->field('shipping_street'); + $s->field('shipping_city'); +}); +``` + +## Nested Sections + +Sections can contain other sections: + +```php +$layout->section('Product', function(Section $s) { + $s->field('title'); + $s->field('description'); + + $s->section('Pricing', function(Section $nested) { + $nested->field('price'); + $nested->field('sale_price'); + }); + + $s->section('Inventory', function(Section $nested) { + $nested->field('stock_quantity'); + $nested->field('allow_backorders'); + }); +}); +``` + +## Sections with Tabs + +Embed tabs within a section: + +```php +$layout->section('Product Details', function(Section $s) { + $s->field('title'); + + $s->tabs(function(Tabs $tabs) { + $tabs->tab('Description', function(Tab $t) { + $t->field('short_description'); + $t->field('full_description'); + }); + + $tabs->tab('Specifications', function(Tab $t) { + $t->field('weight'); + $t->field('dimensions'); + }); + }); +}); +``` + +## Multiple Top-Level Sections + +```php +$view->set_layout(function(Layout $layout) { + $layout->section('Basic Info', function(Section $s) { + $s->field('title'); + $s->field('slug'); + }); + + $layout->section('Content', function(Section $s) { + $s->field('body'); + $s->field('excerpt'); + }); + + $layout->section('Settings', function(Section $s) { + $s->field('is_published'); + $s->field('publish_date'); + }); +}); +``` diff --git a/website/docs/layouts/sidebar.md b/website/docs/layouts/sidebar.md new file mode 100644 index 0000000..8d28907 --- /dev/null +++ b/website/docs/layouts/sidebar.md @@ -0,0 +1,130 @@ +--- +sidebar_position: 4 +title: Sidebar +description: Adding sidebars for actions and metadata +--- + +# Sidebar + +The sidebar provides a fixed panel for status information and action buttons. + +## Basic Sidebar + +```php +use Tangible\EditorLayout\Layout; +use Tangible\EditorLayout\Sidebar; + +$view->set_layout(function(Layout $layout) { + $layout->section('Content', function(Section $s) { + $s->field('title'); + $s->field('body'); + }); + + $layout->sidebar(function(Sidebar $sb) { + $sb->actions(['save', 'delete']); + }); +}); +``` + +## Sidebar Fields + +Add fields to the sidebar for quick access: + +```php +$layout->sidebar(function(Sidebar $sb) { + $sb->field('status'); + $sb->field('publish_date'); + $sb->field('is_featured'); + + $sb->actions(['save', 'delete']); +}); +``` + +## Field Options + +Sidebar fields support the same options as section fields: + +```php +$sb->field('created_at')->readonly(); +$sb->field('status')->help('Current publication status'); +``` + +## Actions + +The `actions()` method defines which buttons appear: + +```php +// Save and delete buttons +$sb->actions(['save', 'delete']); + +// Save only (for settings pages) +$sb->actions(['save']); +``` + +## Sidebar for Settings Pages + +For singular mode (settings), typically only a save button is needed: + +```php +$view = new DataView([ + 'slug' => 'settings', + 'mode' => 'singular', + 'storage' => 'option', + 'fields' => [...], +]); + +$view->set_layout(function(Layout $layout) { + $layout->section('API', function(Section $s) { + $s->field('api_key'); + $s->field('api_url'); + }); + + $layout->sidebar(function(Sidebar $sb) { + $sb->actions(['save']); + }); +}); +``` + +## Complete Example + +A product editor with status sidebar: + +```php +$view->set_layout(function(Layout $layout) { + // Main content area + $layout->tabs(function(Tabs $tabs) { + $tabs->tab('General', function(Tab $t) { + $t->field('title'); + $t->field('description'); + $t->field('price'); + }); + + $tabs->tab('Inventory', function(Tab $t) { + $t->field('sku'); + $t->field('stock_quantity'); + $t->field('allow_backorders'); + }); + }); + + // Sidebar with status and actions + $layout->sidebar(function(Sidebar $sb) { + // Status fields + $sb->field('status') + ->help('Publication status'); + $sb->field('is_featured') + ->help('Show on homepage'); + + // Read-only metadata + $sb->field('created_at')->readonly(); + $sb->field('updated_at')->readonly(); + $sb->field('view_count')->readonly(); + + // Action buttons + $sb->actions(['save', 'delete']); + }); +}); +``` + +## Sidebar Position + +The sidebar is typically rendered on the right side of the form, following WordPress admin conventions. The exact styling depends on the renderer being used. diff --git a/website/docs/layouts/tabs.md b/website/docs/layouts/tabs.md new file mode 100644 index 0000000..0c4c854 --- /dev/null +++ b/website/docs/layouts/tabs.md @@ -0,0 +1,160 @@ +--- +sidebar_position: 3 +title: Tabs +description: Organizing content with tabbed navigation +--- + +# Tabs + +Tabs organize content into separate panels, reducing visual clutter for complex forms. + +## Basic Tabs + +```php +use Tangible\EditorLayout\Layout; +use Tangible\EditorLayout\Tabs; +use Tangible\EditorLayout\Tab; + +$view->set_layout(function(Layout $layout) { + $layout->tabs(function(Tabs $tabs) { + $tabs->tab('General', function(Tab $t) { + $t->field('title'); + $t->field('description'); + }); + + $tabs->tab('Settings', function(Tab $t) { + $t->field('is_published'); + $t->field('publish_date'); + }); + }); +}); +``` + +## Tab Content + +Tabs can contain fields, sections, or nested tabs. + +### Fields in Tabs + +```php +$tabs->tab('Content', function(Tab $t) { + $t->field('title') + ->placeholder('Enter title'); + $t->field('body') + ->help('Main content area'); +}); +``` + +### Sections in Tabs + +```php +$tabs->tab('Details', function(Tab $t) { + $t->section('Basic', function(Section $s) { + $s->field('name'); + $s->field('email'); + }); + + $t->section('Address', function(Section $s) { + $s->field('street'); + $s->field('city'); + }); +}); +``` + +### Nested Tabs + +```php +$tabs->tab('Advanced', function(Tab $t) { + $t->tabs(function(Tabs $nested) { + $nested->tab('SEO', function(Tab $seo) { + $seo->field('meta_title'); + $seo->field('meta_description'); + }); + + $nested->tab('Social', function(Tab $social) { + $social->field('og_title'); + $social->field('og_image'); + }); + }); +}); +``` + +## Tabs Inside Sections + +Tabs can be placed inside sections for organized layouts: + +```php +$layout->section('Product', function(Section $s) { + $s->field('title'); + $s->field('price'); + + $s->tabs(function(Tabs $tabs) { + $tabs->tab('Description', function(Tab $t) { + $t->field('short_description'); + $t->field('full_description'); + }); + + $tabs->tab('Images', function(Tab $t) { + $t->field('main_image'); + $t->field('gallery'); + }); + + $tabs->tab('Inventory', function(Tab $t) { + $t->field('sku'); + $t->field('stock_quantity'); + }); + }); +}); +``` + +## Complete Example + +A blog post editor with multiple tab groups: + +```php +$view->set_layout(function(Layout $layout) { + // Main content tabs + $layout->tabs(function(Tabs $tabs) { + $tabs->tab('Content', function(Tab $t) { + $t->field('title') + ->placeholder('Post title'); + $t->field('body') + ->help('Main post content'); + $t->field('excerpt') + ->help('Short summary for listings'); + }); + + $tabs->tab('Media', function(Tab $t) { + $t->field('featured_image'); + $t->field('gallery'); + }); + + $tabs->tab('SEO', function(Tab $t) { + $t->section('Meta Tags', function(Section $s) { + $s->field('meta_title'); + $s->field('meta_description'); + }); + + $t->section('Social Sharing', function(Section $s) { + $s->field('og_title'); + $s->field('og_description'); + $s->field('og_image'); + }); + }); + + $tabs->tab('Advanced', function(Tab $t) { + $t->field('slug'); + $t->field('custom_css'); + $t->field('custom_js'); + }); + }); + + // Sidebar + $layout->sidebar(function(Sidebar $sb) { + $sb->field('status'); + $sb->field('publish_date'); + $sb->field('author'); + $sb->actions(['save', 'delete']); + }); +}); +``` diff --git a/website/docs/renderers/custom-renderers.md b/website/docs/renderers/custom-renderers.md new file mode 100644 index 0000000..06a0026 --- /dev/null +++ b/website/docs/renderers/custom-renderers.md @@ -0,0 +1,208 @@ +--- +sidebar_position: 4 +title: Custom Renderers +description: Building your own renderer +--- + +# Custom Renderers + +You can create custom renderers to control exactly how forms are displayed. + +## Implementing the Interface + +Create a class that implements the `Renderer` interface: + +```php +use Tangible\Renderer\Renderer; +use Tangible\DataObject\DataSet; +use Tangible\EditorLayout\Layout; + +class MyCustomRenderer implements Renderer { + + public function render_editor(Layout $layout, array $data = []): string { + // Render the edit/create form + } + + public function render_list(DataSet $dataset, array $entities): string { + // Render the list view + } +} +``` + +## Using Your Renderer + +```php +$view = new DataView([...]); +$view->set_renderer(new MyCustomRenderer()); +$view->register(); +``` + +## Accessing Layout Structure + +The layout provides its structure for rendering: + +```php +public function render_editor(Layout $layout, array $data = []): string { + $structure = $layout->get_structure(); + $dataset = $layout->get_dataset(); + + // Structure format: + // [ + // 'items' => [ + // ['type' => 'section', 'label' => 'General', 'fields' => [...]], + // ['type' => 'tabs', 'tabs' => [...]], + // ], + // 'sidebar' => [ + // 'fields' => [...], + // 'actions' => ['save', 'delete'], + // ], + // ] + + $html = '
'; + + foreach ($structure['items'] as $item) { + $html .= $this->render_item($item, $data, $dataset); + } + + if (!empty($structure['sidebar'])) { + $html .= $this->render_sidebar($structure['sidebar'], $data, $dataset); + } + + $html .= '
'; + + return $html; +} +``` + +## Rendering Items + +Handle different item types: + +```php +protected function render_item(array $item, array $data, DataSet $dataset): string { + switch ($item['type']) { + case 'section': + return $this->render_section($item, $data, $dataset); + case 'tabs': + return $this->render_tabs($item, $data, $dataset); + case 'field': + return $this->render_field($item, $data, $dataset); + default: + return ''; + } +} + +protected function render_section(array $section, array $data, DataSet $dataset): string { + $html = '
'; + $html .= '

' . esc_html($section['label']) . '

'; + + foreach ($section['items'] ?? [] as $item) { + $html .= $this->render_item($item, $data, $dataset); + } + + foreach ($section['fields'] ?? [] as $field) { + $html .= $this->render_field($field, $data, $dataset); + } + + $html .= '
'; + return $html; +} +``` + +## Rendering Fields + +Get field info from the dataset: + +```php +protected function render_field(array $field, array $data, DataSet $dataset): string { + $slug = $field['slug']; + $value = $data[$slug] ?? ''; + $type = $dataset->get_type($slug); + + $html = '
'; + $html .= ''; + + // Render input based on type + switch ($type) { + case DataSet::TYPE_BOOLEAN: + $checked = $value ? 'checked' : ''; + $html .= ''; + break; + + case DataSet::TYPE_INTEGER: + $html .= ''; + break; + + default: + $html .= ''; + } + + if (!empty($field['help'])) { + $html .= '

' . esc_html($field['help']) . '

'; + } + + $html .= '
'; + return $html; +} +``` + +## Complete Example + +A Bootstrap-styled renderer: + +```php +class BootstrapRenderer implements Renderer { + + public function render_editor(Layout $layout, array $data = []): string { + $structure = $layout->get_structure(); + $dataset = $layout->get_dataset(); + + $html = '
'; + + foreach ($structure['items'] as $item) { + $html .= $this->render_item($item, $data, $dataset); + } + + $html .= '
'; + $html .= '